Compare commits
137
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae8d098a10 | ||
|
|
fb07a81527 | ||
|
|
1c9c4319fd | ||
|
|
2ba29dcc60 | ||
|
|
029c7dab5a | ||
|
|
91d2fff24d | ||
|
|
f34c63ef46 | ||
|
|
e7e59fc513 | ||
|
|
6206b2cace | ||
|
|
fce824b471 | ||
|
|
285332c44a | ||
|
|
fc3bab4ea7 | ||
|
|
8edd8ad6ee | ||
|
|
982ba07fea | ||
|
|
02ceb1e456 | ||
|
|
fe59907f56 | ||
|
|
bd3d258616 | ||
|
|
030a843f07 | ||
|
|
d25183e89d | ||
|
|
8e6246493f | ||
|
|
3e361dc08d | ||
|
|
c2b9ce859e | ||
|
|
4549e28fa5 | ||
|
|
55ad1bfee0 | ||
|
|
afb3fcff8d | ||
|
|
510316e65d | ||
|
|
408c93c56c | ||
|
|
9559d3a15a | ||
|
|
fbd1145623 | ||
|
|
c776db2f5e | ||
|
|
c66ebb1593 | ||
|
|
cdf18e1262 | ||
|
|
95951d57c9 | ||
|
|
b3ebdd2b73 | ||
|
|
eeb6b0635f | ||
|
|
8de7165912 | ||
|
|
a9fd9b9713 | ||
|
|
414593d78f | ||
|
|
7a97e87a71 | ||
|
|
748f3842c8 | ||
|
|
6ed63ba310 | ||
|
|
7ff08c3663 | ||
|
|
7fc3f098c7 | ||
|
|
0c2a0b1971 | ||
|
|
9ed50f2b0f | ||
|
|
e4f0250c74 | ||
|
|
bed35e3bc3 | ||
|
|
8914b21bad | ||
|
|
56a3e0538f | ||
|
|
4bd9e1484c | ||
|
|
a70788e585 | ||
|
|
335abf5b22 | ||
|
|
05fc3fc849 | ||
|
|
89c6b8edc4 | ||
|
|
4c30347139 | ||
|
|
542abb86a1 | ||
|
|
a74cc19ef3 | ||
|
|
fedd522b21 | ||
|
|
2e8d8e0d6c | ||
|
|
716f68391f | ||
|
|
5bf08ac5af | ||
|
|
282ec87512 | ||
|
|
32043f3c01 | ||
|
|
5520e3c575 | ||
|
|
a65677ffca | ||
|
|
d1c6806024 | ||
|
|
ff92a46936 | ||
|
|
4e766701e8 | ||
|
|
b606c06a9d | ||
|
|
9ddb4467d2 | ||
|
|
28acddb4b9 | ||
|
|
ae31ac00fb | ||
|
|
6ac48240a1 | ||
|
|
0e97fbb0f8 | ||
|
|
8474dbfb4f | ||
|
|
c3ab4bdc21 | ||
|
|
e404924aef | ||
|
|
3d4b2ec1d1 | ||
|
|
f204844fec | ||
|
|
0b91e37806 | ||
|
|
58a3e039b3 | ||
|
|
9592e1aec5 | ||
|
|
ad9a50c584 | ||
|
|
603c9654ef | ||
|
|
23b12ef94e | ||
|
|
c1872fe41b | ||
|
|
1a0eaf1dee | ||
|
|
cbbe03e81d | ||
|
|
3439e1ec6f | ||
|
|
741805ffde | ||
|
|
da8867b0e7 | ||
|
|
8e3072b95e | ||
|
|
7b8a5850df | ||
|
|
806d5ecc7d | ||
|
|
7317dbe4e5 | ||
|
|
5b4d6e9daf | ||
|
|
a07ad33306 | ||
|
|
09f4de0607 | ||
|
|
5c551c3105 | ||
|
|
221ac33e81 | ||
|
|
aef35a456b | ||
|
|
1724d98ae9 | ||
|
|
8a3ac5ad78 | ||
|
|
cdf0e46914 | ||
|
|
b0c0c923fc | ||
|
|
462e79abe2 | ||
|
|
dbc1263232 | ||
|
|
c2e84eaa3d | ||
|
|
7dd5bd7608 | ||
|
|
85408c100d | ||
|
|
937c5f9a56 | ||
|
|
e6cbe3ddac | ||
|
|
f072aab248 | ||
|
|
852aedf849 | ||
|
|
d25894a473 | ||
|
|
f60c593686 | ||
|
|
252fbf7fee | ||
|
|
af156a3c3a | ||
|
|
fd30e71dba | ||
|
|
b8cd50fd63 | ||
|
|
74faf9cf40 | ||
|
|
eaab09896b | ||
|
|
0c27c0793a | ||
|
|
a83b923363 | ||
|
|
9e59cffbbe | ||
|
|
4e7ebf7f87 | ||
|
|
043bc1ee78 | ||
|
|
2094436577 | ||
|
|
79769e6a47 | ||
|
|
22588ea7c4 | ||
|
|
fbaf2f1d28 | ||
|
|
f1abfc2fb1 | ||
|
|
61bb15fbde | ||
|
|
1e4ce036c6 | ||
|
|
5779943540 | ||
|
|
74446990f8 | ||
|
|
8cd7baea5e |
@@ -10,6 +10,7 @@
|
||||
* 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = 자원 이름 단추
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton, createIconButton } from "@ui/ui_template_button";
|
||||
import { createRowMenu } from "@ui/ui_template_context_menu";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
@@ -97,6 +98,36 @@ const KIND_KEY: Record<MoneyKind, LsKey> = {
|
||||
율: "K_율",
|
||||
};
|
||||
|
||||
/** 가져온 기계 찾기 판정(서버 기계 찾기 · 문서 `가져온.기계`) — 그 단가 기호 칸 */
|
||||
interface FoundMach {
|
||||
기호?: string;
|
||||
판정?: string;
|
||||
찾기어?: string;
|
||||
조합?: boolean;
|
||||
후보?: { 이름?: string; 규격?: string }[];
|
||||
}
|
||||
|
||||
function machineFound(d: FormulaDoc, sym: string): FoundMach | undefined {
|
||||
const head = (d as { 가져온?: { 기계?: FoundMach[] } }).가져온;
|
||||
return head?.기계?.find((x) => x.기호 === sym);
|
||||
}
|
||||
|
||||
/** 못 이은 가져온 기계 줄 표지(고르면 사라짐 — 연결이 단가산출로 바뀜) · 조합 기계 다음 줄 */
|
||||
function machineMark(f: FoundMach | undefined, link: { 갈래?: string }): HTMLElement | null {
|
||||
if (!f) return null;
|
||||
const picked = link.갈래 === "단가산출";
|
||||
const key = f.판정 === "후보 여럿" ? "Mach_Many" : f.판정 === "못 찾음" ? "Mach_None" : null;
|
||||
const badge =
|
||||
key && !picked
|
||||
? createBadge(lt(key), "warning", { size: 18 })
|
||||
: f.조합
|
||||
? createBadge(lt("Mach_Combo"), "neutral", { size: 18 })
|
||||
: null;
|
||||
if (badge && key && f.후보?.length)
|
||||
badge.title = f.후보.map((c) => `${c.이름 ?? ""} ${c.규격 ?? ""}`.trim()).join(" · ");
|
||||
return badge;
|
||||
}
|
||||
|
||||
/** 금액 줄 몸 — 한 줄(804-7 · 요구 687-7): [종류][이름 단추 | 이름 칸 · 단가 식][수량][단위][비목] · 좁으면 접힘 */
|
||||
function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
const d = h.doc;
|
||||
@@ -122,6 +153,8 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
|
||||
: h.name(link.키)
|
||||
: "";
|
||||
// 가져온 기계 줄(812-9) — 찾기 판정 · 조회어 = 기종 이름만(규격 뺌 · 이은 우리 기계 › 정규화 이름)
|
||||
const found = kind === "기계" ? machineFound(d, own) : undefined;
|
||||
const pick = createButton({
|
||||
label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—",
|
||||
variant: "ghost",
|
||||
@@ -133,13 +166,15 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
kind,
|
||||
(p) => h.change((doc) => repick(doc, i, own, kind, p)),
|
||||
undefined,
|
||||
String(mat?.검색어 ?? line.이름 ?? ""),
|
||||
found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? ""),
|
||||
),
|
||||
});
|
||||
pick.classList.add("lstk-res");
|
||||
pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`;
|
||||
pick.dataset.repick = String(i);
|
||||
head.push(pick);
|
||||
const mark = machineMark(found, link);
|
||||
if (mark) head.push(mark);
|
||||
} else
|
||||
head.push(
|
||||
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
|
||||
|
||||
@@ -248,7 +248,11 @@ function rows(
|
||||
}
|
||||
});
|
||||
if (Object.keys(at).length) scope.번지 = at;
|
||||
const name = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" ");
|
||||
const spec = (line as { 규격?: string }).규격;
|
||||
const name =
|
||||
spec && !String(line.이름 ?? "").includes(spec)
|
||||
? `${line.이름 ?? ""} ${spec}`.trim()
|
||||
: String(line.이름 ?? ""); // 이름에 규격이 들면 한 번만(814-1)
|
||||
const qty =
|
||||
line.수량 !== undefined && line.수량 !== null && line.수량 !== "" ? line.수량 : null;
|
||||
const qtyNode = (cost?: string): (string | HTMLElement)[] =>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function buildSourced(
|
||||
if (guess) f.root.dataset.guess = mark;
|
||||
return guess
|
||||
? cell(f.root, lt("Guess"), "warning", lt("Guess_Hint"))
|
||||
: cell(f.root, lt("Src_Native"), "info", lt("Src_Hint"));
|
||||
: cell(f.root, lt(head.원파일 ? "Src_Native" : "Src_Excel"), "info", lt("Src_Hint")); // 812-1 엑셀 읽기기 = 「엑셀」
|
||||
};
|
||||
if (head.규격) out.push(ro(lt("Spec"), head.규격, "", false));
|
||||
for (const g of head.추정 ?? [])
|
||||
|
||||
@@ -14,7 +14,7 @@ const TEXT = {
|
||||
Add_Item: ["+ 그룹", "+ Group"],
|
||||
Add_Line: ["줄 더함", "Add line"],
|
||||
Add_율: ["율 — 그룹 소계의 %", "Rate — % of subtotal"],
|
||||
Add_식: ["식 — 공식 틀", "Formula"],
|
||||
Add_식: ["식", "Formula"],
|
||||
Del_Item: ["그룹 지움", "Delete group"],
|
||||
Del: ["지움", "Delete"],
|
||||
Item: ["그룹", "Group"],
|
||||
@@ -87,6 +87,9 @@ const TEXT = {
|
||||
],
|
||||
Direct_Hint: ["표 값을 손으로 고침 — 표 연결 끊김", "Table value typed by hand — table link off"],
|
||||
Tbl_Missing: ["표를 못 읽음", "Table not found"],
|
||||
Mach_Many: ["기계 후보 여럿 — 고르기", "Several machines — pick one"],
|
||||
Mach_None: ["기계 못 찾음 — 고르기", "Machine not found — pick one"],
|
||||
Mach_Combo: ["조합 기계 — 경비는 첫 줄", "Combined machine — cost on first line"],
|
||||
Tbl_Hint: ["값 칸을 누르면 그 값이 들어감", "Click a value cell to use it"],
|
||||
Tbl_Bound: [
|
||||
"이 값의 줄은 위 옵션이 고름(다른 줄은 옵션에서 바꿈 · 여기선 칸만)",
|
||||
|
||||
@@ -5,14 +5,10 @@
|
||||
저절로: 지난번 이음 · 하나 수 · 남은 · 자리 · 이은(`board`)}
|
||||
POST /api/projects/{id}/estimation/link/apply
|
||||
{pairs [{line, target, mode?}], auto, mode, counts?, user?} · 알림 남은 = 안 이은 항목 수
|
||||
잇기(`link`) — `auto` = 지난번 이음 · 하나도 더함 →
|
||||
{user(새 캐시), 알림, payload(그 캐시로 셈)} ·
|
||||
이은 짝 = 맞춤 기억에 씀(회사 층 = 회사 관리자 · 아니면 개인 층)
|
||||
POST …/link/undo {target, user?} → {user, 알림, payload} — 잇기 풀기(맞춤 기억 그대로)
|
||||
POST …/link/forget {key, user?} → {잊음, 기억층, 남음} — 이 짝 잊기(불러온 줄 · 이은 줄 키 →
|
||||
맞춤 열쇠 · 쓸 수 있는 층에 잊음 표시 `{잊음: True}` — 느슨한 기억도 안 불러옴 ·
|
||||
개인 층 표시가 회사 기억을 덮음 · 남음 = 그래도 살아 있는 기억)
|
||||
맞춤 기억 = 층 `실무자료/내역맞춤.json`(실무자료 맞춤 꼴) · 읽기 = 회사 + 개인(개인이 이김).
|
||||
잇기(`link`) → {user(새 캐시), 알림, payload(그 캐시로 셈)}
|
||||
POST …/link/undo {target, user?} → {user, 알림, payload} — 잇기 풀기
|
||||
공사 내역 맞춤 기억(`실무자료/내역맞춤.json` · 지난번 이음 · 잊기)은 걷음(815-2 · 요구 765-2) —
|
||||
제안은 짝 표만 · 옛 기억 파일은 읽지도 쓰지도 않음(지우지 않음).
|
||||
끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) · 화면 = `B09_Estimation_UI_Link`.
|
||||
"""
|
||||
|
||||
@@ -30,42 +26,9 @@ from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B09_Estimation import B09_Estimation_Bill_Link as linker
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as m02_store
|
||||
|
||||
router = APIRouter()
|
||||
PATH = "/{project_id}/estimation/link"
|
||||
LAYERS = ("company", "personal")
|
||||
|
||||
|
||||
def _folders(session: dict[str, Any]) -> dict[str, tuple[Path, bool]]:
|
||||
"""{층: (폴더, 쓰기)} — 회사 없음 등 = 그 층 건너뜀."""
|
||||
out = {}
|
||||
for layer in LAYERS:
|
||||
try:
|
||||
got = access.grant(session, layer)
|
||||
out[layer] = (access.folder(got, create=True), got.write)
|
||||
except HTTPException:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def recalled(session: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""맞춤 기억 — 마스터(시스템 관리자 승인 · 718-2) 위에 회사 · 그 위에 개인을 덮음."""
|
||||
out: dict[str, dict[str, Any]] = dict(hopyo_store.memory(m02_store.FOLDER, linker.MEMORY))
|
||||
for folder, _write in _folders(session).values():
|
||||
out.update(hopyo_store.memory(folder, linker.MEMORY))
|
||||
return out
|
||||
|
||||
|
||||
def keep(session: dict[str, Any], entries: dict[str, dict[str, Any]]) -> str | None:
|
||||
"""이은 짝 → 회사 층(쓸 수 있으면) · 아니면 개인 층 → 쓴 층(없음 = None)."""
|
||||
folders = _folders(session)
|
||||
layer = next((n for n in LAYERS if n in folders and folders[n][1]), None)
|
||||
if entries and layer:
|
||||
hopyo_store.remember(folders[layer][0], entries, linker.MEMORY)
|
||||
return layer if entries else None
|
||||
|
||||
|
||||
def _draft(raw: Any) -> dict[str, Any] | None:
|
||||
@@ -87,11 +50,6 @@ class UndoBody(BaseModel):
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ForgetBody(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=200)
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ApplyBody(BaseModel):
|
||||
pairs: list[Pair] = Field(default_factory=list, max_length=2000)
|
||||
auto: bool = False
|
||||
@@ -115,8 +73,7 @@ async def suggest(
|
||||
rows = await _rows(project_id, root, draft)
|
||||
board = linker.board(draft, rows)
|
||||
left = {r["key"] for r in board["남은"]}
|
||||
memory = await asyncio.to_thread(recalled, session)
|
||||
lines = [s for s in linker.suggest(rows, memory) if s["key"] in left]
|
||||
lines = [s for s in linker.suggest(rows, {}) if s["key"] in left]
|
||||
return {"줄": lines, "저절로": len(linker.auto_pairs(lines)), **board}
|
||||
|
||||
|
||||
@@ -134,15 +91,14 @@ async def apply(
|
||||
rows = await _rows(project_id, root, draft)
|
||||
pairs = [(p.line, p.target) for p in body.pairs]
|
||||
if body.auto:
|
||||
memory = await asyncio.to_thread(recalled, session)
|
||||
pairs += [p for p in linker.auto_pairs(linker.suggest(rows, memory)) if p not in pairs]
|
||||
pairs += [p for p in linker.auto_pairs(linker.suggest(rows, {})) if p not in pairs]
|
||||
made, report = linker.link(draft, rows, pairs, body.mode, body.counts, modes)
|
||||
made = store.clean_user(made)
|
||||
layer = await asyncio.to_thread(keep, session, report.pop("기억"))
|
||||
report.pop("기억")
|
||||
out = await payload(project_id, root, made, True)
|
||||
left = linker.board(made, out["sheets"]["bill"]["rows"])["남은"]
|
||||
report["남은"] = sum(1 for r in left if r["kind"] == "item")
|
||||
return {"user": made, "알림": {**report, "기억층": layer}, "payload": out}
|
||||
return {"user": made, "알림": report, "payload": out}
|
||||
|
||||
|
||||
@router.post(PATH + "/undo")
|
||||
@@ -162,21 +118,3 @@ async def undo(
|
||||
"알림": {"풀음": 1},
|
||||
"payload": await payload(project_id, root, made, True),
|
||||
}
|
||||
|
||||
|
||||
@router.post(PATH + "/forget")
|
||||
async def forget(
|
||||
project_id: UUID, body: ForgetBody, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root)
|
||||
rows = await _rows(project_id, root, draft)
|
||||
board = linker.board(draft, rows)
|
||||
hit = next((r for r in board["남은"] if r["key"] == body.key), None)
|
||||
memo = linker.key_of(hit) if hit else None
|
||||
memo = memo or next((r["memo"] for r in board["이은"] if r["key"] == body.key), None)
|
||||
if not memo:
|
||||
raise HTTPException(status_code=400, detail="불러온 줄 · 이은 줄이 아님")
|
||||
layer = await asyncio.to_thread(keep, session, {memo: {linker.FORGOT: True}})
|
||||
left = bool((await asyncio.to_thread(recalled, session)).get(memo, {}).get("확정"))
|
||||
return {"잊음": memo, "기억층": layer, "남음": left}
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
* 우리 자동 줄(토공 · 운반 · 구조물)에 이어 B08 수량이 붙게. 저장 안 함(받은 캐시 → [저장] 때 서버 정본).
|
||||
*
|
||||
* 길 = `B09_Estimation_Router_Link.py` link/suggest(제안 · 남은 줄 · 빈 자리 · 이은 줄) ·
|
||||
* link/apply(잇기 · 맞춤 기억 씀) · link/undo(잇기 풀기) · link/forget(이 짝 잊기) — 셈 = 서버 단독
|
||||
* link/apply(잇기) · link/undo(잇기 풀기) — 셈 = 서버 단독 · 맞춤 기억 · 잊기는 걷음(815-2)
|
||||
* 창 = 제안 줄(꼬리표 지난번 이음 · 짝 표 · 이름 같음 · 후보 고름 · 수량 갈래 · 묶음 개수 · [잇기]) ·
|
||||
* 제안 없는 줄([우리 공종으로 잇기] = 단가연결 고르기 창 재사용 · 우리 줄만) · 이은 줄([잇기 풀기] ·
|
||||
* [이 짝 잊기]) · 머리 [모두 제안대로](짧은 길 = 불러오기 → 모두 제안대로 → [저장])
|
||||
* 제안 없는 줄([우리 공종으로 잇기] = 단가연결 고르기 창 재사용 · 우리 줄만) · 이은 줄([잇기 풀기]) · 머리 [모두 제안대로](짧은 길 = 불러오기 → 모두 제안대로 → [저장])
|
||||
* 수량 갈래 기본 = 불러올 때 고른 것(가 넘겨받기 · 나 단가만) · 줄에서 바꿈 ·
|
||||
* 묶음 나 = 개수 칸(빈칸 = B08 수량 · 다르면 수량 칸 붉게)
|
||||
* 고리 = 불러오기 끝(`…_UI_Import`) · 설계내역서 우클릭(`…_UI_Tabs_Bill_Edit`)
|
||||
@@ -185,14 +184,6 @@ export async function openBillLink(
|
||||
.map((s) => [s.key, Number(pickOf(s).count)])
|
||||
.filter(([, n]) => Number.isFinite(n) && (n as number) > 0),
|
||||
);
|
||||
const forget = (key: string): void =>
|
||||
run(async () => {
|
||||
const got = await post("forget", { key, user: cur });
|
||||
showToast(
|
||||
got.남음 ? "이 짝 잊음 — 회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음",
|
||||
got.남음 ? "warning" : "success",
|
||||
);
|
||||
});
|
||||
|
||||
const suggestRow = (s: Row): HTMLElement => {
|
||||
const p = pickOf(s);
|
||||
@@ -233,10 +224,6 @@ export async function openBillLink(
|
||||
onClick: () => run(() => linkPairs(cur, done, [pairOf(s)], countsOf([s]))),
|
||||
}),
|
||||
);
|
||||
if (s.꼬리표 === "지난번 이음")
|
||||
items.push(
|
||||
createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(s.key) }),
|
||||
);
|
||||
return createListRow({
|
||||
label: label(s),
|
||||
sub: s.까닭 || (s.kind === "item" ? "" : "묶음 → 구조물 줄 통째"),
|
||||
@@ -269,7 +256,6 @@ export async function openBillLink(
|
||||
variant: "ghost",
|
||||
onClick: () => run(() => undoLink(cur, done, r.key)),
|
||||
}),
|
||||
createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(r.key) }),
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -326,23 +312,15 @@ async function undoLink(user: Row, done: LinkDone, target: string): Promise<Row>
|
||||
return got.user;
|
||||
}
|
||||
|
||||
/** 설계내역서 우클릭 — 불러온 줄 = 잇기 · 이은 줄(`was`) = 풀기 · 잊기 */
|
||||
/** 설계내역서 우클릭 — 불러온 줄 = 잇기 · 이은 줄(`was`) = 풀기 */
|
||||
export function linkMenu(ctx: TabContext, key: string): MapContextMenuItem[] {
|
||||
const done: LinkDone = (payload, user) => {
|
||||
Object.assign(ctx.payload, payload);
|
||||
ctx.setUser(user);
|
||||
};
|
||||
const entry = ((ctx.user.bill?.rows as Row[]) || []).find((e) => e.key === key);
|
||||
const forget = (): void =>
|
||||
void post("forget", { key, user: ctx.user }).then(
|
||||
(got) => showToast(got.남음 ? "회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음", "info"),
|
||||
fail("못 잊음"),
|
||||
);
|
||||
if (entry?.was)
|
||||
return [
|
||||
["잇기 풀기", () => void undoLink(ctx.user, done, key).catch(fail("못 풂"))],
|
||||
["이 짝 잊기", forget],
|
||||
];
|
||||
return [["잇기 풀기", () => void undoLink(ctx.user, done, key).catch(fail("못 풂"))]];
|
||||
if (!key.startsWith("hand:x")) return [];
|
||||
return [["우리 공종으로 잇기", () => void openBillLink(ctx.user, done)]];
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
"""M01 별칭 장부 시스템 모으기(PLAN 718-2 · 요구서 내역12 4장 23 · 4-1 확정 6).
|
||||
"""M01 별칭 모아 보기 · 제안 → 승인(PLAN 815-2 · 요구 764 · 765) — 서버 단독.
|
||||
|
||||
모든 회사 · 개인 층의 별칭 장부(`실무자료/맞춤.json` · `M02_Hopyo_Ledger`)와 B09 공사 내역 맞춤
|
||||
기억(`실무자료/내역맞춤.json` · 707-4)을 읽어 후보로 모음 — 읽기만(공유 storage 안 씀).
|
||||
후보 = (종류, 열쇠, 서명) · 서명 = 짝 내용 지문(같은 짝이면 같은 서명) · 회사 수 · 사람 수만
|
||||
(누가 · 처음파일 · 층 주인은 안 내보냄 = 익명) · 충돌 = 같은 열쇠 다른 서명 · 마스터와 다름.
|
||||
승인 = 시스템 관리자만 → 마스터 층에 씀(맞춤 = `ledger.put` 재검사 · 내역맞춤 = 기억 파일) ·
|
||||
반려 = 마스터 층 `실무자료/별칭반려.json` 에 서명 기억(같은 짝은 다시 안 보임) · 자동 반영 없음.
|
||||
별칭 = 요소 · 로직 줄 칸 `별칭` 하나(콤마 여러 개 · 풀이 정본 `master_alias`).
|
||||
GET /api/m01/alias → {줄: 별칭이 적힌 줄 전부(갈래 · 키 · 이름 · 규격 · 별칭 · 조각 ·
|
||||
들어옴 수기/승인), 대기: 승인 대기 제안}
|
||||
POST /api/m01/alias/save {키, 별칭} → 그 줄 `별칭` 만 고침(M01 저장 길 `make.save`)
|
||||
POST /api/m01/alias/approve {id} → 그 요소 `별칭` 에 콤마로 더함 · 제안 = 「승인」 기록
|
||||
POST /api/m01/alias/reject {id} → 제안 지움
|
||||
POST /api/m01/alias/suggest {키, 이름, 규격?, 단위?(갈래는 서버가 키로 정함)} → 제안 하나(로그인만 · 일반 계정도)
|
||||
제안 파일 = `storage/_shared/별칭제안.json` `{제안: {id: {…, 상태 대기|승인}}}`.
|
||||
권한 = `router` 시스템 관리자 · `suggest_router` 로그인만(`main.py`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -20,160 +25,161 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_time import utc_now_text
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
ALIAS, BILL = "맞춤", "내역맞춤"
|
||||
KINDS = (ALIAS, BILL)
|
||||
REJECT = "별칭반려"
|
||||
import master_alias as ma # noqa: E402 (`store` 가 scripts 를 길에 둠)
|
||||
|
||||
router = APIRouter(prefix="/api/m01/alias-ledger", tags=["M01 별칭 장부"])
|
||||
ALIAS = "별칭"
|
||||
FUEL = "재료_유가전력.json"
|
||||
ELEMENTS = ("인력.json", "기계.json", "재료_자재품목.json", FUEL) # 제안을 받는 요소 파일
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
router = APIRouter(prefix="/api/m01/alias", tags=["M01 별칭"])
|
||||
suggest_router = APIRouter(prefix="/api/m01/alias", tags=["M01 별칭"])
|
||||
|
||||
|
||||
# ── 읽기 ─────────────────────────────────────────────────────────────
|
||||
def layer_folders() -> list[tuple[str, str | None, Path]]:
|
||||
"""(회사, 사용자 | None, 층 폴더) — storage 의 회사 · 개인 층(숫자 폴더만 · 두 단만 훑음)."""
|
||||
root = layers.storage_root()
|
||||
out: list[tuple[str, str | None, Path]] = []
|
||||
for c in sorted(root.iterdir() if root.is_dir() else []):
|
||||
if not (c.is_dir() and c.name.isdigit()):
|
||||
def kind_of(file: str, group: str) -> str:
|
||||
if file == FUEL:
|
||||
return "유가"
|
||||
return "단가산출 기초" if group == "로직" else group
|
||||
|
||||
|
||||
# ── 제안 파일 ─────────────────────────────────────────────────────────
|
||||
def suggest_path() -> Path:
|
||||
return layers.storage_root() / "_shared" / "별칭제안.json"
|
||||
|
||||
|
||||
def _book() -> dict[str, dict[str, Any]]:
|
||||
try:
|
||||
got = json.loads(suggest_path().read_text(encoding="utf-8")).get("제안")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return {}
|
||||
return got if isinstance(got, dict) else {}
|
||||
|
||||
|
||||
def _keep(book: dict[str, dict[str, Any]]) -> None:
|
||||
path = suggest_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps({"제안": book}, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
# ── 줄 찾기 · 고치기 ───────────────────────────────────────────────────
|
||||
def _find(key: str, files: tuple[str, ...] | None = None) -> tuple[str, dict[str, Any]]:
|
||||
loaded, _ = store.loaded()
|
||||
for name, data in loaded.items():
|
||||
if files and name not in files:
|
||||
continue
|
||||
out.append((c.name, None, layers.company_dir(c.name)))
|
||||
for u in sorted(c.iterdir()):
|
||||
if u.is_dir() and u.name.isdigit():
|
||||
out.append((c.name, u.name, layers.personal_dir(c.name, u.name)))
|
||||
return out
|
||||
for row in data.get("줄") or []:
|
||||
if str(row.get("키")) == key:
|
||||
return name, row
|
||||
raise store.StoreError(404, f"없는 키 「{key}」")
|
||||
|
||||
|
||||
def _body(kind: str, row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""서명에 드는 내용 — 맞춤 = 갈래 · 짝 · 내역맞춤 = 확정 줄 키 · 이름."""
|
||||
if kind == ALIAS:
|
||||
return {"갈래": row.get("갈래"), "짝": row.get("짝") or []}
|
||||
return {"확정": row.get("확정"), "이름": row.get("이름")}
|
||||
def set_alias(key: str, text: str) -> dict[str, Any]:
|
||||
"""그 줄 `별칭` 만 바꿔 M01 저장 길로(빈 글 = 칸 없음 · 별칭만이라 정본 개정 확인 안 물음)."""
|
||||
file, _ = _find(key)
|
||||
data, version = store.read(file)
|
||||
row = dict(next(r for r in data["줄"] if str(r.get("키")) == key))
|
||||
row[ALIAS] = text
|
||||
change = {"op": "edit", "key": key, "row": row}
|
||||
make.save([{"file": file, "version": version, "changes": [change]}], True)
|
||||
return board()
|
||||
|
||||
|
||||
def sign(kind: str, row: dict[str, Any]) -> str:
|
||||
raw = json.dumps(_body(kind, row), ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
def _quoted(piece: str) -> str:
|
||||
return f'"{piece}"' if any(s in piece for s in (",", ",")) else piece
|
||||
|
||||
|
||||
def _book(kind: str, folder: Path) -> dict[str, dict[str, Any]]:
|
||||
if kind == ALIAS:
|
||||
return ledger.read(folder)
|
||||
got = hopyo_store.memory(folder, BILL)
|
||||
return {
|
||||
k: v
|
||||
for k, v in got.items()
|
||||
if isinstance(v, dict) and v.get("확정") and str(k).count("|") == 2
|
||||
} # 잊음 · 깨진 열쇠 뺌
|
||||
def _joined(text: Any, piece: str) -> str:
|
||||
"""별칭 글 끝에 한 조각 — 이미 있으면(정규화 같음) 그대로."""
|
||||
raw = str(text or "").strip()
|
||||
if ma.norm(piece) in {ma.norm(x) for x in ma.names(raw)}:
|
||||
return raw
|
||||
return f"{raw}, {_quoted(piece)}" if raw else _quoted(piece)
|
||||
|
||||
|
||||
def _origin(full: str, row: dict[str, Any]) -> dict[str, str]:
|
||||
if isinstance(row.get("원래"), dict):
|
||||
return {k: str(row["원래"].get(k) or "") for k in ("품명", "규격", "단위")}
|
||||
return ledger._plain_of(full)
|
||||
# ── 모아 보기 ─────────────────────────────────────────────────────────
|
||||
def board() -> dict[str, Any]:
|
||||
book = _book()
|
||||
approved = {
|
||||
(str(v.get("키")), ma.norm(v.get(ALIAS))) for v in book.values() if v.get("상태") == "승인"
|
||||
}
|
||||
rows = []
|
||||
loaded, _ = store.loaded()
|
||||
for file, data in loaded.items():
|
||||
kind = kind_of(file, str(data.get("그룹") or ""))
|
||||
for row in data.get("줄") or []:
|
||||
if not str(row.get(ALIAS) or "").strip():
|
||||
continue
|
||||
key = str(row.get("키"))
|
||||
pieces = [
|
||||
{"글": p, "승인": (key, ma.norm(p)) in approved} for p in ma.names(row[ALIAS])
|
||||
]
|
||||
rows.append(
|
||||
{
|
||||
"갈래": kind,
|
||||
"파일": file,
|
||||
"키": key,
|
||||
"이름": str(row.get("이름") or ""),
|
||||
"규격": str(row.get("규격") or ""),
|
||||
ALIAS: str(row[ALIAS]),
|
||||
"조각": pieces,
|
||||
"들어옴": "승인" if any(p["승인"] for p in pieces) else "수기",
|
||||
}
|
||||
)
|
||||
waiting = [{"id": k, **v} for k, v in book.items() if v.get("상태") == "대기"]
|
||||
waiting.sort(key=lambda x: str(x.get("때") or ""))
|
||||
return {"줄": rows, "대기": waiting}
|
||||
|
||||
|
||||
def rejected() -> dict[str, dict[str, Any]]:
|
||||
return hopyo_store.memory(store.FOLDER, REJECT)
|
||||
# ── 제안 · 승인 · 반려 ────────────────────────────────────────────────
|
||||
def suggest(key: str, name: str, spec: str, unit: str, who: Any) -> dict[str, Any]:
|
||||
file, row = _find(key, ELEMENTS)
|
||||
piece = " ".join(x for x in (name.strip(), spec.strip()) if x)
|
||||
if not piece:
|
||||
raise store.StoreError(400, "가져온 이름이 비었음")
|
||||
if _joined(row.get(ALIAS), piece) == str(row.get(ALIAS) or "").strip():
|
||||
return {"이미": True} # 이미 그 별칭이 있음
|
||||
sid = hashlib.sha256(f"{key}|{ma.norm(piece)}".encode()).hexdigest()[:16]
|
||||
with _LOCK:
|
||||
book = _book()
|
||||
if book.get(sid, {}).get("상태") != "승인":
|
||||
book[sid] = {
|
||||
"갈래": kind_of(file, str(store.loaded()[0][file].get("그룹") or "")),
|
||||
"키": key,
|
||||
"이름": name.strip(),
|
||||
"규격": spec.strip(),
|
||||
"단위": unit.strip(),
|
||||
ALIAS: piece,
|
||||
"상태": "대기",
|
||||
"누가": who,
|
||||
"때": utc_now_text(),
|
||||
}
|
||||
_keep(book)
|
||||
return {"id": sid}
|
||||
|
||||
|
||||
def _reject_id(kind: str, full: str, sig: str) -> str:
|
||||
return f"{kind}|{sig}|{full}"
|
||||
def decide(sid: str, ok: bool, who: Any) -> dict[str, Any]:
|
||||
with _LOCK:
|
||||
book = _book()
|
||||
one = book.get(sid)
|
||||
if not one or one.get("상태") != "대기":
|
||||
raise store.StoreError(404, "대기 중인 제안이 없음(이미 승인 · 반려)")
|
||||
if ok:
|
||||
_, row = _find(str(one["키"]), ELEMENTS)
|
||||
set_alias(str(one["키"]), _joined(row.get(ALIAS), str(one[ALIAS])))
|
||||
one.update(상태="승인", 승인={"누가": who, "때": utc_now_text()})
|
||||
else:
|
||||
del book[sid]
|
||||
_keep(book)
|
||||
return board()
|
||||
|
||||
|
||||
def gather() -> dict[str, Any]:
|
||||
"""후보 모음 `{후보, 모은곳{회사, 사람}, 반려}` — 마스터와 같은 짝 · 반려한 짝은 뺌."""
|
||||
seen: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||
firms, people = set(), set()
|
||||
for company, user, folder in layer_folders():
|
||||
for kind in KINDS:
|
||||
for full, row in _book(kind, folder).items():
|
||||
sig = sign(kind, row)
|
||||
who = (
|
||||
(row.get("누가") or {}).get("id") if isinstance(row.get("누가"), dict) else None
|
||||
)
|
||||
person = f"{company}/{user}" if user else f"{company}/{who or '-'}"
|
||||
one = seen.setdefault(
|
||||
(kind, full, sig),
|
||||
{"종류": kind, "열쇠": full, "서명": sig, "원래": _origin(full, row),
|
||||
**_body(kind, row), "회사": set(), "사람": set(), "때": None},
|
||||
) # fmt: skip
|
||||
one["회사"].add(company)
|
||||
one["사람"].add(person)
|
||||
firms.add(company)
|
||||
people.add(person)
|
||||
stamp = row.get("때")
|
||||
if stamp and (one["때"] is None or str(stamp) > str(one["때"])):
|
||||
one["때"] = stamp
|
||||
master = {kind: _book(kind, Path(store.FOLDER)) for kind in KINDS}
|
||||
gone = rejected()
|
||||
live = []
|
||||
for (kind, full, sig), one in seen.items():
|
||||
mine = master[kind].get(full)
|
||||
if (mine and sign(kind, mine) == sig) or _reject_id(kind, full, sig) in gone:
|
||||
continue
|
||||
one["마스터"] = "다름" if mine else None
|
||||
live.append(one)
|
||||
many: dict[tuple[str, str], int] = {}
|
||||
for one in live:
|
||||
many[(one["종류"], one["열쇠"])] = many.get((one["종류"], one["열쇠"]), 0) + 1
|
||||
out = []
|
||||
for one in sorted(live, key=lambda x: (x["종류"], x["열쇠"], -len(x["사람"]), x["서명"])):
|
||||
clash = many[(one["종류"], one["열쇠"])] > 1 or one["마스터"] == "다름"
|
||||
out.append({**one, "회사": len(one["회사"]), "사람": len(one["사람"]), "충돌": clash})
|
||||
return {"후보": out, "모은곳": {"회사": len(firms), "사람": len(people)}, "반려": len(gone)}
|
||||
|
||||
|
||||
def _find(kind: str, full: str, sig: str) -> dict[str, Any]:
|
||||
if kind not in KINDS:
|
||||
raise store.StoreError(400, f"종류는 {' · '.join(KINDS)}")
|
||||
got = next(
|
||||
(x for x in gather()["후보"] if (x["종류"], x["열쇠"], x["서명"]) == (kind, full, sig)),
|
||||
None,
|
||||
)
|
||||
if got is None:
|
||||
raise store.StoreError(404, f"후보가 없음(이미 승인 · 반려 · 바뀜) 「{full}」")
|
||||
return got
|
||||
|
||||
|
||||
# ── 쓰기(마스터 층만) ─────────────────────────────────────────────────
|
||||
def approve(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]:
|
||||
"""후보 하나 → 마스터 장부(서버가 다시 모아 찾음 · 맞춤은 `ledger.put` 재검사)."""
|
||||
one = _find(kind, full, sig)
|
||||
if kind == ALIAS:
|
||||
ledger.put(store.FOLDER, {full: {"원래": one["원래"], **_body(kind, one)}}, who)
|
||||
else:
|
||||
row = {**_body(kind, one), "누가": who, "때": utc_now_text()}
|
||||
hopyo_store.remember(store.FOLDER, {full: row}, BILL)
|
||||
return gather()
|
||||
|
||||
|
||||
def reject(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]:
|
||||
_find(kind, full, sig)
|
||||
row = {"종류": kind, "열쇠": full, "서명": sig, "누가": who, "때": utc_now_text()}
|
||||
hopyo_store.remember(store.FOLDER, {_reject_id(kind, full, sig): row}, REJECT)
|
||||
return gather()
|
||||
|
||||
|
||||
def master_bill() -> list[dict[str, Any]]:
|
||||
"""마스터 내역맞춤 줄(보기 · 지움) — 맞춤 장부는 `/api/m02/layers/system/hopyo-ledger`."""
|
||||
book = _book(BILL, Path(store.FOLDER))
|
||||
return [
|
||||
{"열쇠": k, "원래": ledger._plain_of(k), **_body(BILL, v)} for k, v in sorted(book.items())
|
||||
]
|
||||
|
||||
|
||||
def forget_bill(full: str) -> list[dict[str, Any]]:
|
||||
if full not in _book(BILL, Path(store.FOLDER)):
|
||||
raise store.StoreError(404, f"마스터 내역맞춤에 없음 「{full}」")
|
||||
hopyo_store.remember(store.FOLDER, {full: None}, BILL)
|
||||
return master_bill()
|
||||
|
||||
|
||||
# ── 길(시스템 관리자만 — main.py 에서 system_admin_only) ───────────────
|
||||
# ── 길 ───────────────────────────────────────────────────────────────
|
||||
def _who(session: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"id": session.get("user_id"), "이름": session.get("name")}
|
||||
|
||||
@@ -185,31 +191,44 @@ def _call(fn, *args):
|
||||
raise HTTPException(error.status, error.detail) from error
|
||||
|
||||
|
||||
class Pick(BaseModel):
|
||||
종류: str = Field(max_length=10)
|
||||
열쇠: str = Field(min_length=1, max_length=400)
|
||||
서명: str = Field(min_length=16, max_length=16)
|
||||
class AliasBody(BaseModel):
|
||||
키: str = Field(min_length=1, max_length=20)
|
||||
별칭: str = Field(max_length=2000)
|
||||
|
||||
|
||||
class Decide(BaseModel):
|
||||
id: str = Field(min_length=16, max_length=16)
|
||||
|
||||
|
||||
class Suggest(BaseModel):
|
||||
갈래: str = Field(default="", max_length=20)
|
||||
키: str = Field(min_length=1, max_length=20)
|
||||
이름: str = Field(min_length=1, max_length=200)
|
||||
규격: str = Field(default="", max_length=200)
|
||||
단위: str = Field(default="", max_length=40)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def candidates() -> dict[str, Any]:
|
||||
return {**gather(), "마스터내역": master_bill()}
|
||||
def get_board() -> dict[str, Any]:
|
||||
return board()
|
||||
|
||||
|
||||
@router.post("/save")
|
||||
def post_save(body: AliasBody) -> dict[str, Any]:
|
||||
return _call(set_alias, body.키, body.별칭)
|
||||
|
||||
|
||||
@router.post("/approve")
|
||||
def approve_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]:
|
||||
return _call(approve, body.종류, body.열쇠, body.서명, _who(session))
|
||||
def post_approve(body: Decide, session: dict = Depends(verify_session)) -> dict[str, Any]:
|
||||
return _call(decide, body.id, True, _who(session))
|
||||
|
||||
|
||||
@router.post("/reject")
|
||||
def reject_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]:
|
||||
return _call(reject, body.종류, body.열쇠, body.서명, _who(session))
|
||||
def post_reject(body: Decide, session: dict = Depends(verify_session)) -> dict[str, Any]:
|
||||
return _call(decide, body.id, False, _who(session))
|
||||
|
||||
|
||||
class Forget(BaseModel):
|
||||
열쇠: str = Field(min_length=1, max_length=400)
|
||||
|
||||
|
||||
@router.post("/master-bill/forget")
|
||||
def forget_one(body: Forget) -> dict[str, Any]:
|
||||
return {"마스터내역": _call(forget_bill, body.열쇠)}
|
||||
@suggest_router.post("/suggest")
|
||||
def post_suggest(body: Suggest, session: dict = Depends(verify_session)) -> dict[str, Any]:
|
||||
args = (body.키, body.이름, body.규격, body.단위, _who(session))
|
||||
return _call(suggest, *args)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""부품 문서(PLAN 813-2 · 요구 722-3 · 724~735) — 수식 문서 머리 칸 `부품` 읽기.
|
||||
|
||||
갈래 = 기계경비식(기계 시간당 경비 셈 방식) · 작업량 틀(`기종` = 기계표 상세구분 목록 ·
|
||||
`규격` = 고른 기계 규격 수를 받는 변수 열쇠) · 계수식(기계 무관 · 돈 아닌 식).
|
||||
틀 = `_수식문서_틀.md` 9 장.
|
||||
- 단가산출 기초 목록 · 찾아 넣기 「다른 단가산출」 목록에서 뺌(`strip`) — 키로 열기 · 셈 · 부르기는 그대로
|
||||
- 기종 → 그 기종 기계가 쓰는 기계경비식 · 작업량 틀(`of_kind` · M01 「기계」 화면)
|
||||
- 기계 원문번호 → 그 기종 작업량 틀 문서(`of_machine` · 줄 쌓기 기계 넣기)
|
||||
쓰기 없음 · 로그인 사용자 모두(마스터 읽기).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Doc as doc_store
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
KINDS = ("기계경비식", "작업량 틀", "계수식")
|
||||
|
||||
|
||||
def parts() -> dict[str, dict]:
|
||||
"""{문서 키: 부품 표지}."""
|
||||
return {k: d["부품"] for k, d in doc_store.docs().items() if isinstance(d.get("부품"), dict)}
|
||||
|
||||
|
||||
def strip(rows: list[dict]) -> list[dict]:
|
||||
"""목록에서 부품 문서를 뺌."""
|
||||
have = parts()
|
||||
return [r for r in rows if str(r.get("키")) not in have]
|
||||
|
||||
|
||||
def _brief(key: str, d: dict) -> dict[str, Any]:
|
||||
tag = d.get("부품") or {}
|
||||
return {
|
||||
"키": key,
|
||||
"이름": d.get("이름") or "",
|
||||
"결과단위": d.get("결과단위") or "",
|
||||
"갈래": tag.get("갈래") or "",
|
||||
"규격": tag.get("규격") or "",
|
||||
}
|
||||
|
||||
|
||||
def _machines() -> list[dict]:
|
||||
files = store.loaded()[0]
|
||||
return list((files.get("기계.json") or {}).get("줄") or [])
|
||||
|
||||
|
||||
def _templates(kind: str) -> list[tuple[str, dict]]:
|
||||
return [
|
||||
(k, d)
|
||||
for k, d in doc_store.docs().items()
|
||||
if (d.get("부품") or {}).get("갈래") == "작업량 틀" and kind in d["부품"].get("기종", [])
|
||||
]
|
||||
|
||||
|
||||
def of_kind(group: str, detail: str = "") -> dict[str, list]:
|
||||
"""M01 화면 — 계수식 = 그 넷 · 기계 = 상세구분 `detail` 기계가 쓰는 기계경비식(기계 수)
|
||||
+ 작업량 틀."""
|
||||
docs = doc_store.docs()
|
||||
if group == "계수식":
|
||||
return {
|
||||
"계수식": [
|
||||
_brief(k, d) for k, d in docs.items() if parts().get(k, {}).get("갈래") == "계수식"
|
||||
]
|
||||
}
|
||||
from M02_MasterTemplete import M02_Machine_Hourly as hourly # M02 가 M01 을 부름 — 늦게
|
||||
|
||||
used: dict[str, int] = {}
|
||||
for m in _machines():
|
||||
if m.get("상세구분") != detail:
|
||||
continue
|
||||
got = hourly.logic_of(str(m.get("원문번호") or ""))
|
||||
if got:
|
||||
used[str(got["키"])] = used.get(str(got["키"]), 0) + 1
|
||||
rule = [{**_brief(k, docs[k]), "기계수": n} for k, n in used.items() if k in docs]
|
||||
return {"기계경비식": rule, "작업량 틀": [_brief(k, d) for k, d in _templates(detail)]}
|
||||
|
||||
|
||||
def of_machine(no: str) -> dict[str, Any]:
|
||||
"""줄 쌓기 기계 넣기 — 기계 원문번호 → {기계 {이름 · 규격 · 상세구분}, 틀 [{… 문서}]}."""
|
||||
row = next((m for m in _machines() if str(m.get("원문번호")) == str(no)), None)
|
||||
if not row:
|
||||
raise store.StoreError(404, f"없는 기계 「{no}」")
|
||||
kind = str(row.get("상세구분") or "")
|
||||
return {
|
||||
"기계": {"이름": row.get("이름"), "규격": row.get("규격") or "", "상세구분": kind},
|
||||
"틀": [{**_brief(k, d), "문서": d} for k, d in _templates(kind)],
|
||||
}
|
||||
@@ -14,6 +14,7 @@ from pydantic import BaseModel
|
||||
|
||||
from M01_MasterData import M01_MasterData_Doc as doc_screen
|
||||
from M01_MasterData import M01_MasterData_Equation as equation
|
||||
from M01_MasterData import M01_MasterData_Parts as parts
|
||||
from M01_MasterData import M01_MasterData_Sheet as line_sheet
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
@@ -205,7 +206,14 @@ def get_logics(
|
||||
sub: str = "", detail: str = "", q: str = "", blocked: int | None = None, owner: str = ""
|
||||
) -> dict:
|
||||
"""줄마다 덧칸(묶음 · 작업량 · 부르는/불리는 수 · 관련 · 713-3) — 읽기만."""
|
||||
return {"logics": store_list.attach(_call(store.logics, sub, detail, q, blocked, owner))}
|
||||
rows = parts.strip(_call(store.logics, sub, detail, q, blocked, owner)) # 부품 문서 뺌(813-2)
|
||||
return {"logics": store_list.attach(rows)}
|
||||
|
||||
|
||||
@router.get("/parts")
|
||||
def get_parts(group: str, detail: str = "") -> dict:
|
||||
"""부품 문서(813-2) — 계수식 · 기계 상세구분의 기계경비식 · 작업량 틀."""
|
||||
return _call(parts.of_kind, group, detail)
|
||||
|
||||
|
||||
@router.get("/logic")
|
||||
|
||||
@@ -283,6 +283,15 @@ def save(batch: list[dict], confirmed: bool = False) -> dict:
|
||||
"""`/save` — 로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise`) ·
|
||||
자체 파일 새 줄(키 없음)은 서버가 칸을 잡고 GX 다음 키 — {files, added, warnings}."""
|
||||
revise.guard(batch, confirmed)
|
||||
for part in batch:
|
||||
for ch in part.get("changes") or []:
|
||||
row = ch.get("row")
|
||||
if (
|
||||
isinstance(row, dict) and "별칭" in row
|
||||
): # 별칭(815-2) = 앞뒤 빈칸만 뗌 · 빈 글 = 칸 없음
|
||||
text = str(row["별칭"] or "").strip()
|
||||
row.update(별칭=text) if text else row.pop("별칭")
|
||||
_keep_same(part)
|
||||
fresh = False
|
||||
for part in batch:
|
||||
if part.get("file") != OWN:
|
||||
@@ -308,6 +317,20 @@ def save(batch: list[dict], confirmed: bool = False) -> dict:
|
||||
return {"files": files, "added": added, "warnings": []}
|
||||
|
||||
|
||||
def _keep_same(part: dict) -> None:
|
||||
"""고친 줄의 값이 같은 칸 = 파일 값 그대로(화면 왕복 「9.0」 → 「9」 막음 · 그 칸만 바뀜)."""
|
||||
try:
|
||||
data = store.read(str(part.get("file") or ""))[0]
|
||||
except store.StoreError:
|
||||
return
|
||||
rows = {str(r.get("키")): r for r in data.get(store.items_key(data)) or []}
|
||||
for ch in part.get("changes") or []:
|
||||
old, row = rows.get(str(ch.get("key"))), ch.get("row")
|
||||
if ch.get("op") == "edit" and isinstance(old, dict) and isinstance(row, dict):
|
||||
same = {k for k, v in row.items() if k in old and store._dec(v) == old[k]}
|
||||
ch["row"] = {k: old[k] if k in same else v for k, v in row.items()}
|
||||
|
||||
|
||||
def _file_keys(file: str) -> list[str]:
|
||||
try:
|
||||
data = store.read(file)[0]
|
||||
|
||||
@@ -1,67 +1,55 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Alias.ts
|
||||
* M01 별칭 장부(PLAN 718-3 · 요구서 내역12 4장 23 · 4-1 확정 6) — 본 칸 · 시스템 관리자만.
|
||||
* · 「모인 후보」 = 모든 회사 · 개인 층 별칭 장부(일위대가 맞춤) + 공사 내역 맞춤을 서버가 모음
|
||||
* (`/api/m01/alias-ledger` · 718-2) · 이름 · 짝 · 회사 수 · 사람 수(누군지 안 보임) · 충돌 ·
|
||||
* [승인] = 마스터 장부에 넣음(모든 사용자 제안에 쓰임) · [반려] = 다시 안 보임 · 둘 다 확인 창
|
||||
* · 「마스터 장부」 = M02 별칭 장부 부품(`buildHopyoLedger` · 마스터 층) 그대로 + 마스터 공사 내역 맞춤 줄 [지움]
|
||||
* · 자동 반영 없음 — 승인한 것만 모두에게 · 승인 · 반려 · 지움은 누를 때 바로 서버(확인 창 뒤)
|
||||
* M01 별칭 모아 보기(PLAN 815-2 · 요구 764 · 765) — 본 칸 · 시스템 관리자만.
|
||||
* · 위 = 「승인 대기 n」 — 일반 사용자 제안(가져온 이름 · 규격 → 우리 요소) · [승인] = 그 요소 별칭에
|
||||
* 콤마로 더함 · [반려] = 지움(둘 다 확인 창 뒤 바로 서버)
|
||||
* · 아래 = 별칭이 적힌 줄 전부(인력 · 기계 · 재료 · 유가 · 단가산출 기초 — 수기로 넣은 것도 바로) ·
|
||||
* 갈래 거름 · 찾기 · 별칭 칸 눌러 고침(콤마로 여러 개) → [저장](그 줄 별칭만 · `/api/m01/alias/save`) ·
|
||||
* 줄을 누르면 그 요소 화면 그 줄 · 단가산출 기초는 로직 탭
|
||||
* · 서버 = `M01_MasterData_Alias.py` · 조각 나누기 = 서버(`master_alias.names`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { askConfirm, createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { createPanelHead } from "@ui/ui_template_panel_head";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createTable, type Column } from "@ui/ui_template_table";
|
||||
import { createTabs } from "@ui/ui_template_tabs";
|
||||
import "@ui/ui_template_general_layout.css"; // 마스터 장부 부품(M02 Hopyo_Ledger) 표 꼴 — M01 엔 안 실려 있음
|
||||
import type { LedgerPair } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
buildHopyoLedger,
|
||||
KIND_TEXT,
|
||||
LEDGER_HINT,
|
||||
pairText,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger";
|
||||
import { startEdit } from "./M01_MasterData_UI_Cells";
|
||||
|
||||
export const ALIAS_TITLE = "별칭 장부";
|
||||
const KIND_NAME: Record<string, string> = { 맞춤: "일위대가", 내역맞춤: "공사 내역" };
|
||||
export const ALIAS_TITLE = "별칭 모아 보기";
|
||||
const KINDS = ["인력", "기계", "재료", "유가", "단가산출 기초"];
|
||||
|
||||
interface Origin {
|
||||
품명: string;
|
||||
export interface AliasRow {
|
||||
갈래: string;
|
||||
파일: string;
|
||||
키: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
별칭: string;
|
||||
조각: { 글: string; 승인: boolean }[];
|
||||
들어옴: "수기" | "승인";
|
||||
}
|
||||
interface Waiting {
|
||||
id: string;
|
||||
갈래: string;
|
||||
키: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
단위: string;
|
||||
별칭: string;
|
||||
누가?: { 이름?: string } | null;
|
||||
때?: string;
|
||||
}
|
||||
export interface AliasCandidate {
|
||||
종류: "맞춤" | "내역맞춤";
|
||||
열쇠: string;
|
||||
서명: string;
|
||||
원래: Origin;
|
||||
갈래?: string;
|
||||
짝?: LedgerPair[];
|
||||
확정?: string;
|
||||
이름?: string;
|
||||
회사: number;
|
||||
사람: number;
|
||||
때: string | null;
|
||||
충돌: boolean;
|
||||
마스터: "다름" | null;
|
||||
}
|
||||
interface MasterBill {
|
||||
열쇠: string;
|
||||
원래: Origin;
|
||||
확정: string;
|
||||
이름?: string;
|
||||
}
|
||||
export interface AliasBoard {
|
||||
후보: AliasCandidate[];
|
||||
모은곳: { 회사: number; 사람: number };
|
||||
반려: number;
|
||||
마스터내역?: MasterBill[];
|
||||
interface Board {
|
||||
줄: AliasRow[];
|
||||
대기: Waiting[];
|
||||
}
|
||||
|
||||
async function call<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}/m01/alias-ledger${path}`, {
|
||||
const res = await fetch(`${API_BASE_URL}/m01/alias${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -72,72 +60,75 @@ async function call<T>(path: string, body?: unknown): Promise<T> {
|
||||
return got;
|
||||
}
|
||||
|
||||
/** 짝 글 — 일위대가 = 갈래 · 우리 로직 · 공사 내역 = 우리 공종 이름 */
|
||||
export const candidateText = (c: AliasCandidate): string =>
|
||||
c.종류 === "내역맞춤"
|
||||
? `${c.이름 ?? ""} (${c.확정 ?? ""})`
|
||||
: c.갈래 === "짝"
|
||||
? (c.짝 ?? []).map(pairText).join(" + ")
|
||||
: (KIND_TEXT[c.갈래 ?? ""] ?? c.갈래 ?? "");
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const pick = (c: AliasCandidate) => ({ 종류: c.종류, 열쇠: c.열쇠, 서명: c.서명 });
|
||||
|
||||
const CANDIDATE_COLS: Column[] = [
|
||||
{ label: "종류", width: "84px" },
|
||||
{ label: "엑셀 품명" },
|
||||
{ label: "규격" },
|
||||
const WAIT_COLS: Column[] = [
|
||||
{ label: "갈래", width: "96px" },
|
||||
{ label: "가져온 이름 · 규격" },
|
||||
{ label: "단위", width: "64px" },
|
||||
{ label: "짝" },
|
||||
{ label: "회사", width: "56px", align: "end" },
|
||||
{ label: "사람", width: "56px", align: "end" },
|
||||
{ label: "충돌", width: "96px" },
|
||||
{ label: "우리 요소" },
|
||||
{ label: "누가 · 때", width: "180px" },
|
||||
{ label: "", width: "136px" },
|
||||
];
|
||||
const BILL_COLS: Column[] = [
|
||||
{ label: "엑셀 품명" },
|
||||
{ label: "규격" },
|
||||
{ label: "단위", width: "64px" },
|
||||
{ label: "우리 공종" },
|
||||
{ label: "", width: "72px" },
|
||||
const ROW_COLS: Column[] = [
|
||||
{ label: "갈래", width: "96px" },
|
||||
{ label: "키", width: "96px" },
|
||||
{ label: "이름 · 규격" },
|
||||
{ label: "별칭" },
|
||||
{ label: "들어옴", width: "72px" },
|
||||
];
|
||||
|
||||
export function buildAliasLedger(): HTMLElement {
|
||||
const root = el("div", { className: "m01-alias", attrs: { "data-alias-ledger": "" } });
|
||||
const body = el("div", { className: "m01-alias__body" });
|
||||
let view = "candidates";
|
||||
/** `open` = 줄을 눌렀을 때(왼쪽 판이 그 요소 화면 · 로직 탭으로) · `setDirty` = 저장 안 한 고침 표시 */
|
||||
export function buildAliasBoard(opts: {
|
||||
open: (row: AliasRow) => void;
|
||||
setDirty?: (dirty: boolean) => void;
|
||||
}): HTMLElement {
|
||||
const root = el("div", { className: "m01-alias", attrs: { "data-alias-board": "" } });
|
||||
const waitHost = el("div");
|
||||
const listHost = el("div");
|
||||
const edits = new Map<string, string>();
|
||||
let board: Board = { 줄: [], 대기: [] };
|
||||
let kind = "";
|
||||
let q = "";
|
||||
|
||||
async function act(c: AliasCandidate, ok: boolean): Promise<void> {
|
||||
const name = `${c.원래.품명} ${c.원래.규격}`.trim();
|
||||
const save = createButton({ label: "저장", variant: "filled", disabled: true });
|
||||
const undo = createButton({ label: "되돌림", variant: "ghost", disabled: true });
|
||||
const mark = (): void => {
|
||||
save.disabled = undo.disabled = edits.size === 0;
|
||||
opts.setDirty?.(edits.size > 0);
|
||||
};
|
||||
|
||||
async function decide(w: Waiting, ok: boolean): Promise<void> {
|
||||
const yes = await askConfirm({
|
||||
title: ok ? "마스터 장부에 승인" : "후보 반려",
|
||||
title: ok ? "별칭 승인" : "제안 반려",
|
||||
message: ok
|
||||
? `「${name}」 → ${candidateText(c)}\n마스터 장부에 넣으면 모든 사용자 제안에 쓰임(각자 장부가 먼저).`
|
||||
: `「${name}」 → ${candidateText(c)}\n반려하면 같은 짝은 후보에 다시 안 보임.`,
|
||||
? `「${w.별칭}」 → ${w.키}\n그 요소 별칭에 더함(다음 불러오기부터 이어짐).`
|
||||
: `「${w.별칭}」 → ${w.키}\n반려하면 제안이 지워짐.`,
|
||||
okLabel: ok ? "승인" : "반려",
|
||||
danger: !ok,
|
||||
});
|
||||
if (!yes) return;
|
||||
try {
|
||||
const got = await call<AliasBoard>(ok ? "/approve" : "/reject", pick(c));
|
||||
showToast(ok ? "마스터 장부에 넣음" : "반려함", "success");
|
||||
paintCandidates(got);
|
||||
board = await call<Board>(ok ? "/approve" : "/reject", { id: w.id });
|
||||
showToast(ok ? "별칭에 더함" : "반려함", "success");
|
||||
paint();
|
||||
} catch (error) {
|
||||
showToast(`${ok ? "승인" : "반려"} 못 함 — ${why(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function candidateRow(c: AliasCandidate): (string | Node)[] {
|
||||
const clash = c.마스터 === "다름" ? "마스터와 다름" : c.충돌 ? "다른 짝 있음" : "";
|
||||
return [
|
||||
KIND_NAME[c.종류] ?? c.종류,
|
||||
c.원래.품명,
|
||||
c.원래.규격,
|
||||
c.원래.단위,
|
||||
candidateText(c),
|
||||
String(c.회사),
|
||||
String(c.사람),
|
||||
clash ? createBadge(clash, "warning") : "",
|
||||
const target = (key: string): string => {
|
||||
const one = board.줄.find((r) => r.키 === key);
|
||||
return one ? `${key} · ${one.이름} ${one.규격}`.trim() : key;
|
||||
};
|
||||
|
||||
function paintWaiting(): void {
|
||||
const rows = board.대기.map((w) => [
|
||||
w.갈래,
|
||||
`${w.이름} ${w.규격}`.trim(),
|
||||
w.단위,
|
||||
target(w.키),
|
||||
`${w.누가?.이름 ?? ""} · ${(w.때 ?? "").slice(0, 16).replace("T", " ")}`,
|
||||
el("div", {
|
||||
className: "ui-toolbar",
|
||||
children: [
|
||||
@@ -145,93 +136,137 @@ export function buildAliasLedger(): HTMLElement {
|
||||
label: "승인",
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => void act(c, true),
|
||||
onClick: () => void decide(w, true),
|
||||
}),
|
||||
createButton({
|
||||
label: "반려",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void act(c, false),
|
||||
onClick: () => void decide(w, false),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function paintCandidates(board: AliasBoard): void {
|
||||
const head = createPanelHead({
|
||||
title: "모인 후보",
|
||||
sub: `${LEDGER_HINT} · 회사 ${board.모은곳.회사} · 사람 ${board.모은곳.사람}의 장부에서 모음 · 반려 ${board.반려} · 승인한 것만 모두에게 · 누가 적었는지는 안 보임`,
|
||||
});
|
||||
body.replaceChildren(
|
||||
head.root,
|
||||
createTable(CANDIDATE_COLS, board.후보.map(candidateRow), {
|
||||
density: "data",
|
||||
empty: "모인 후보 없음",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function forget(b: MasterBill): Promise<void> {
|
||||
const yes = await askConfirm({
|
||||
title: "마스터 공사 내역 맞춤 지움",
|
||||
message: `「${b.원래.품명} ${b.원래.규격}」 → ${b.이름 ?? b.확정}\n지우면 모든 사용자 제안에서 빠짐.`,
|
||||
okLabel: "지움",
|
||||
danger: true,
|
||||
});
|
||||
if (!yes) return;
|
||||
try {
|
||||
const got = await call<{ 마스터내역: MasterBill[] }>("/master-bill/forget", { 열쇠: b.열쇠 });
|
||||
showToast("지움", "success");
|
||||
paintMaster(got.마스터내역);
|
||||
} catch (error) {
|
||||
showToast(`지우지 못 함 — ${why(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function paintMaster(bill: MasterBill[]): void {
|
||||
const rows = bill.map((b) => [
|
||||
b.원래.품명,
|
||||
b.원래.규격,
|
||||
b.원래.단위,
|
||||
`${b.이름 ?? ""} (${b.확정})`,
|
||||
createButton({ label: "지움", variant: "danger", size: "sm", onClick: () => void forget(b) }),
|
||||
]);
|
||||
body.replaceChildren(
|
||||
buildHopyoLedger({ scope: { layer: "system", owner: null }, canWrite: true }),
|
||||
waitHost.replaceChildren(
|
||||
createPanelHead({
|
||||
title: "마스터 공사 내역 맞춤",
|
||||
sub: "B09 내역 줄 ↔ 우리 공종(각자 기억이 먼저)",
|
||||
title: `승인 대기 ${board.대기.length}`,
|
||||
sub: "M02 에서 못 이은 줄을 사용자가 우리 요소로 고른 제안 · 승인 = 그 요소 별칭에 더함",
|
||||
}).root,
|
||||
createTable(BILL_COLS, rows, { density: "data", empty: "마스터 공사 내역 맞춤 없음" }),
|
||||
createTable(WAIT_COLS, rows, { density: "data", empty: "승인 대기 없음" }),
|
||||
);
|
||||
}
|
||||
|
||||
function aliasCell(r: AliasRow): HTMLElement {
|
||||
const box = el("div", { className: "ui-toolbar", attrs: { "data-alias-cell": r.키 } });
|
||||
const edited = edits.get(r.키);
|
||||
if (edited !== undefined) box.append(createBadge(edited || "(지움)", "warning"));
|
||||
else for (const p of r.조각) box.append(createBadge(p.글, p.승인 ? "success" : "neutral"));
|
||||
box.title = "눌러 고침 — 콤마로 여러 개";
|
||||
box.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
startEdit(box, edited ?? r.별칭, (text) => {
|
||||
if (text.trim() === r.별칭.trim()) edits.delete(r.키);
|
||||
else edits.set(r.키, text.trim());
|
||||
mark();
|
||||
paintList();
|
||||
});
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
function paintList(): void {
|
||||
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const shown = board.줄.filter(
|
||||
(r) =>
|
||||
(!kind || r.갈래 === kind) &&
|
||||
words.every((w) => `${r.키} ${r.이름} ${r.규격} ${r.별칭}`.toLowerCase().includes(w)),
|
||||
);
|
||||
const table = createTable(
|
||||
ROW_COLS,
|
||||
shown.map((r) => [
|
||||
r.갈래,
|
||||
r.키,
|
||||
`${r.이름} ${r.규격}`.trim(),
|
||||
aliasCell(r),
|
||||
createBadge(r.들어옴, r.들어옴 === "승인" ? "success" : "neutral"),
|
||||
]),
|
||||
{ density: "data", empty: "별칭이 적힌 줄 없음" },
|
||||
);
|
||||
table.querySelectorAll("tbody tr").forEach((tr, i) => {
|
||||
const r = shown[i];
|
||||
if (!r) return;
|
||||
tr.setAttribute("data-key", r.키);
|
||||
tr.addEventListener("click", () => opts.open(r));
|
||||
});
|
||||
listHost.replaceChildren(table);
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
paintWaiting();
|
||||
paintList();
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const stop = showLoading(body);
|
||||
const stop = showLoading(listHost);
|
||||
try {
|
||||
const got = await call<AliasBoard>("");
|
||||
if (view === "candidates") paintCandidates(got);
|
||||
else paintMaster(got.마스터내역 ?? []);
|
||||
board = await call<Board>("");
|
||||
paint();
|
||||
} catch (error) {
|
||||
body.replaceChildren(loadingError(why(error), () => void load()));
|
||||
listHost.replaceChildren(loadingError(why(error), () => void load()));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = createTabs({
|
||||
tabs: [
|
||||
{ key: "candidates", label: "모인 후보" },
|
||||
{ key: "master", label: "마스터 장부" },
|
||||
],
|
||||
value: view,
|
||||
onChange: (key) => {
|
||||
view = key;
|
||||
void load();
|
||||
save.addEventListener("click", async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
for (const [key, text] of [...edits]) {
|
||||
board = await call<Board>("/save", { 키: key, 별칭: text });
|
||||
edits.delete(key);
|
||||
}
|
||||
showToast("별칭 저장함", "success");
|
||||
} catch (error) {
|
||||
showToast(`저장 못 함 — ${why(error)}`, "error");
|
||||
} finally {
|
||||
mark();
|
||||
paint();
|
||||
}
|
||||
});
|
||||
undo.addEventListener("click", () => {
|
||||
edits.clear();
|
||||
mark();
|
||||
paintList();
|
||||
});
|
||||
|
||||
const kinds = createSegment({
|
||||
options: [{ value: "", label: "전체" }, ...KINDS.map((k) => ({ value: k, label: k }))],
|
||||
value: kind,
|
||||
onChange: (v) => {
|
||||
kind = v;
|
||||
paintList();
|
||||
},
|
||||
ariaLabel: "갈래",
|
||||
});
|
||||
const find = createInputField({
|
||||
type: "search",
|
||||
placeholder: "키 · 이름 · 별칭 찾기",
|
||||
compact: true,
|
||||
onInput: (v) => {
|
||||
q = v.trim();
|
||||
paintList();
|
||||
},
|
||||
});
|
||||
root.append(tabs.root, body); // 제목 = 본 칸 머리(ALIAS_TITLE · 왼쪽 판이 넘김)
|
||||
const listHead = createPanelHead({
|
||||
title: "별칭이 적힌 줄",
|
||||
sub: "수기로 넣은 것 · 승인한 것 모두 · 줄을 누르면 그 요소 화면 · 별칭 칸을 눌러 고친 뒤 [저장]",
|
||||
});
|
||||
root.append(
|
||||
waitHost,
|
||||
listHead.root,
|
||||
el("div", { className: "ui-toolbar", children: [kinds.root, find.root, undo, save] }),
|
||||
listHost,
|
||||
);
|
||||
void load();
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -571,7 +571,13 @@ export async function mountM01LogicLab(
|
||||
showLoadingOverlay();
|
||||
const openKeyOn = async (key: string): Promise<void> => {
|
||||
if (!(await leave())) return;
|
||||
const found = allLogics.find((x) => x.키 === key);
|
||||
// 목록에 없는 부품 문서(813-2) = 키로 읽어 엶
|
||||
const found =
|
||||
allLogics.find((x) => x.키 === key) ??
|
||||
(await fetchLogic(key).then(
|
||||
(o) => o.logic as unknown as { 구분: string; 키: string },
|
||||
() => null,
|
||||
));
|
||||
if (found) await open(logicId(found.구분, found.키), found.구분, found.키);
|
||||
else showToast(tx("Load_Failed"), "error");
|
||||
};
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
.m01lab-src__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere; /* 제목 한 줄이 길면 접음 */
|
||||
}
|
||||
|
||||
.m01lab-src__body {
|
||||
@@ -40,6 +38,11 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* [주] 글 — 본문(--text-body)보다 한 단 작게 */
|
||||
.m01lab-src__note {
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01lab-src__file {
|
||||
margin: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
@@ -56,6 +59,11 @@
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* 표 머리 · 칸 = 본문과 같은 16 — 좁으면 둘레 칸(m01-logic__scroll)이 가로로 굴림 */
|
||||
.m01lab-src .m01lab-src__table :is(td, th) {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 출처가 가리킨 항 · 주 — 옅은 바탕 + 왼쪽 막대 */
|
||||
.m01lab-src__hit {
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
|
||||
@@ -38,6 +38,9 @@ interface Found {
|
||||
item?: string;
|
||||
}
|
||||
|
||||
/** 절 제목 블록의 글만 「번호 이름」 꼴로 */
|
||||
const headFixed = (b: Block): Block => (b.h ? { ...b, text: headText(b.text ?? "") } : b);
|
||||
|
||||
function blockNode(b: Block): HTMLElement {
|
||||
const node = rawNode(b);
|
||||
if (b.hit) node.classList.add("m01lab-src__hit");
|
||||
@@ -65,6 +68,27 @@ function rawNode(b: Block): HTMLElement {
|
||||
return el("p", { className: "m01lab-src__p", text: b.p ?? "" });
|
||||
}
|
||||
|
||||
/** 출처 글에서 첫 번호 앞까지 = 책 · 부문(예 「건설품셈 공통」) — 제목 한 줄의 앞 */
|
||||
const prefixOf = (src: string): string => {
|
||||
const at = src.search(/\d+(?:-\d+)+/);
|
||||
return (at < 0 ? src : src.slice(0, at)).trim();
|
||||
};
|
||||
|
||||
/** 절 제목 줄 「9-7-1. 이름」 → 「9-7-1 이름」 */
|
||||
const headText = (t: string): string => t.replace(/^(\d+(?:-\d+)*)\.\s*/, "$1 ");
|
||||
|
||||
/** 「[주]」 줄부터 다음 제목 앞까지 글 줄 = 한 단 작은 글자(`m01lab-src__note`) */
|
||||
function noteMarked(blocks: Block[]): HTMLElement[] {
|
||||
let inNote = false;
|
||||
return blocks.map((b) => {
|
||||
if (b.h) inNote = false;
|
||||
else if (b.p?.trim().startsWith("[주]")) inNote = true;
|
||||
const node = blockNode(b);
|
||||
if (inNote && b.p !== undefined) node.classList.add("m01lab-src__note");
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
export interface SourceView {
|
||||
root: HTMLElement;
|
||||
/** 그 출처 글의 원문을 그림 — 지금 보이는 것과 같으면 안 부름 */
|
||||
@@ -121,20 +145,28 @@ export function createSourceView(opts: SourceViewOptions = {}): SourceView {
|
||||
.then(({ found }) => {
|
||||
if (shown !== src) return;
|
||||
if (!found.length) return none();
|
||||
// 제목 한 줄 = 「책 부문 + 절 번호 · 이름(개정 꼬리)」 — 절 하나면 원문 제목 줄을 숨김 · 여럿이면 절마다 번호 · 이름 줄 둠
|
||||
const lone = found.length === 1 && found[0].blocks[0]?.h ? found[0] : null;
|
||||
if (found.length > 1 || lone) {
|
||||
const pre = prefixOf(src);
|
||||
title.textContent = lone ? `${pre} ${headText(lone.blocks[0].text ?? "")}`.trim() : pre;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...found.flatMap((f) => [
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted m01lab-src__file",
|
||||
text: [
|
||||
f.file,
|
||||
f.matched === f.number ? "" : ts("Near", f.number),
|
||||
f.item ? (/^\d+$/.test(f.item) ? `${f.item}항` : f.item) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
...f.blocks.map(blockNode),
|
||||
]),
|
||||
...found.flatMap((f) => {
|
||||
// 파일 경로는 안 보임 — 가까운 절 · 항 안내만
|
||||
const note = [
|
||||
f.matched === f.number ? "" : ts("Near", f.number),
|
||||
f.item ? (/^\d+$/.test(f.item) ? `${f.item}항` : f.item) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return [
|
||||
...(note
|
||||
? [el("p", { className: "ui-text ui-text--muted m01lab-src__file", text: note })]
|
||||
: []),
|
||||
...noteMarked(f === lone ? f.blocks.slice(1) : f.blocks.map(headFixed)),
|
||||
];
|
||||
}),
|
||||
);
|
||||
// 강조 항을 패널 몸 가운데로 — 몸만 굴림(85-5 · scrollIntoView 는 메인 창까지 굴림)
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
@@ -77,7 +77,7 @@ export function createSourceFind(opts: {
|
||||
unit: x.결과단위 ?? "",
|
||||
onPick: () => {
|
||||
find.setRows([]);
|
||||
find.hint.textContent = `${x.원문번호 || x.키} ${x.이름}`;
|
||||
find.hint.textContent = ""; // 고른 기초 이름은 원문 제목 한 줄이 보임
|
||||
opts.onPick(x);
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* M01_MasterData_UI_Page.ts
|
||||
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직)
|
||||
* · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b) · 구조물 옵션 정의(716-1 · 화면은 M02 `StructOptions/`)
|
||||
* / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 별칭 장부(718-3) ·
|
||||
* / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 별칭 모아 보기(815-2) ·
|
||||
* 주소 `?open=로직|옵션|별칭` = 그 칸을 고른 채 열림(716-2)
|
||||
*
|
||||
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "./M01_MasterData_UI_Side";
|
||||
import { loadTable } from "./M01_MasterData_UI_Logic_Note";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import { renderParts } from "./M01_MasterData_UI_Parts";
|
||||
import { renderTables } from "./M01_MasterData_UI_Tables";
|
||||
import type { LogicHandle } from "./M01_MasterData_UI_LogicLab";
|
||||
import "./M01_MasterData_UI_Style.css";
|
||||
@@ -84,6 +85,7 @@ function buildPage(): HTMLElement {
|
||||
attrs: { hidden: "" },
|
||||
});
|
||||
const body = el("div", { className: "m01-master__body" });
|
||||
const partsHost = el("div"); // 기계 · 계수 표 아래 부품 문서(813-2)
|
||||
// 제목 줄 · 찾기 줄 = 「단가산출 로직」 화면과 같은 공용 조각
|
||||
const headBar = buildHeadBar({ title, status: [summary], buttons: [drop, save] });
|
||||
const head = el("div", {
|
||||
@@ -99,6 +101,7 @@ function buildPage(): HTMLElement {
|
||||
notice,
|
||||
el("p", { className: "m01-master__hint", text: L("M01_WideHint") }),
|
||||
body,
|
||||
partsHost,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -173,6 +176,7 @@ function buildPage(): HTMLElement {
|
||||
: pick.group === "요율"
|
||||
? renderRateRows(body, pick, query)
|
||||
: renderRows(body, pick, query);
|
||||
renderParts(partsHost, pick, (key) => openLogicTab(key));
|
||||
showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Parts.ts
|
||||
* M01 「기계」 · 「계수」 표 아래 부품 문서(PLAN 813-2 · 요구 722-3 · 724~732) — 단가산출 기초 목록에서 뺀 보조 문서 자리
|
||||
* 기계 · 상세구분(기종) 고름 = 「시간당 기계경비 계산식」(그 기종 기계가 쓰는 방식 · 기계 수) · 「작업량 공식 틀」(그 기종 틀)
|
||||
* 계수 = 「계수식」(기계 무관 · 돈 아닌 식 넷)
|
||||
* 이름 누름 = 줄 쌓기 읽기 창(`createStackScreen` · 고침 없음) · [단가산출 기초에서 열기] = 지금 수식 탭 그대로 고침
|
||||
* 서버 = `GET /api/m01/parts?group&detail`(`M01_MasterData_Parts.of_kind`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createStackScreen } from "../A00_Common/line_stack_screen";
|
||||
import type { Pick } from "./M01_MasterData_UI_Side";
|
||||
|
||||
interface Part {
|
||||
키: string;
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
기계수?: number;
|
||||
}
|
||||
type Parts = Partial<Record<"기계경비식" | "작업량 틀" | "계수식", Part[]>>;
|
||||
|
||||
const T = {
|
||||
기계경비식: ["시간당 기계경비 계산식", "Hourly machine cost formula"],
|
||||
"작업량 틀": ["작업량 공식 틀", "Output formula templates"],
|
||||
계수식: ["계수식", "Coefficient formulas"],
|
||||
Open: ["단가산출 기초에서 열기", "Open in unit price basis"],
|
||||
Machines: ["기계", "machines"],
|
||||
None: ["없음", "None"],
|
||||
Close: ["닫기", "Close"],
|
||||
} as const;
|
||||
const tx = (k: keyof typeof T): string => T[k][currentLanguageIndex === 1 ? 1 : 0];
|
||||
const KINDS = ["기계경비식", "작업량 틀", "계수식"] as const;
|
||||
|
||||
/** 표 아래 부품 칸 — 기계(상세구분 있음) · 계수만 · 그 밖 = 비움 */
|
||||
export function renderParts(host: HTMLElement, pick: Pick, openLogic: (key: string) => void): void {
|
||||
host.replaceChildren();
|
||||
const group = pick.group === "계수" ? "계수식" : pick.group === "기계" ? "기계" : "";
|
||||
if (!group || (group === "기계" && !pick.detail)) return;
|
||||
let stack: ReturnType<typeof createStackScreen> | null = null;
|
||||
/** 줄 쌓기 읽기 창 — 고침 없음 · [단가산출 기초에서 열기] = 수식 탭에서 고침 */
|
||||
const show = (p: Part): void => {
|
||||
stack ??= createStackScreen({ base: "/m01/logic/doc", admin: false, cache: "m01_parts_view" });
|
||||
const view = stack;
|
||||
openModal({
|
||||
title: `${p.키} · ${p.이름}`,
|
||||
closeLabel: tx("Close"),
|
||||
width: "1100px",
|
||||
wide: true,
|
||||
mount: (body, close, actions) => {
|
||||
actions.append(
|
||||
createButton({
|
||||
label: tx("Open"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => {
|
||||
close();
|
||||
openLogic(p.키);
|
||||
},
|
||||
}),
|
||||
);
|
||||
body.append(view.root);
|
||||
view.draw(p.키, true);
|
||||
},
|
||||
});
|
||||
};
|
||||
const q = new URLSearchParams({ group, detail: pick.detail ?? "" });
|
||||
const mine = (host.dataset.partsAsk = q.toString()); // 늦게 온 옛 답은 버림
|
||||
void fetch(`${API_BASE_URL}/m01/parts?${q}`, { credentials: "include" })
|
||||
.then((r): Promise<Parts> | Parts => (r.ok ? (r.json() as Promise<Parts>) : {}))
|
||||
.catch((): Parts => ({}))
|
||||
.then((got) => {
|
||||
if (host.dataset.partsAsk !== mine) return;
|
||||
const blocks = KINDS.filter((k) => k in got).map((k) => {
|
||||
const list = got[k] ?? [];
|
||||
const items = list.map((p) => {
|
||||
const b = createButton({
|
||||
label: `${p.이름}${p.기계수 ? ` · ${tx("Machines")} ${p.기계수}` : ""}`,
|
||||
variant: "ghost",
|
||||
tone: "link",
|
||||
size: 28,
|
||||
onClick: () => show(p),
|
||||
});
|
||||
b.dataset.part = p.키;
|
||||
return b;
|
||||
});
|
||||
return el("section", {
|
||||
className: "m01-parts__block",
|
||||
attrs: { "data-parts": k },
|
||||
children: [
|
||||
el("h3", { className: "m01-parts__title", text: tx(k) }),
|
||||
items.length
|
||||
? el("div", { className: "m01-parts__list", children: items })
|
||||
: createText(tx("None")),
|
||||
],
|
||||
});
|
||||
});
|
||||
if (blocks.length)
|
||||
host.replaceChildren(el("div", { className: "m01-parts", children: blocks }));
|
||||
});
|
||||
}
|
||||
@@ -37,6 +37,9 @@ const MARKET = "재료_자재품목.json";
|
||||
const LINKED = "재료_품셈재료.json";
|
||||
const JOB = ["인력.json"];
|
||||
const NOTE = "비고"; // 끝 열(인력은 준용 앞) · 줄에 없으면 빈 칸 · 눌러 적음
|
||||
// 별칭(815-2 · 요구 764) = 비고 앞 한 열 · 콤마로 여러 개 · 빈 글이면 칸을 지움 · 이 넷만
|
||||
const ALIAS = "별칭";
|
||||
const ALIASED = ["인력.json", "기계.json", MARKET, "재료_유가전력.json"];
|
||||
const FALLBACK: Row = { 키: "", 원문번호: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" };
|
||||
|
||||
/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */
|
||||
@@ -44,16 +47,16 @@ function blank(sample: Row): Row {
|
||||
const out: Row = {};
|
||||
for (const [k, v] of Object.entries(sample)) {
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) out[k] = blank(v as Row);
|
||||
else out[k] = typeof v === "string" && k !== "값" ? "" : null;
|
||||
else if (k !== ALIAS) out[k] = typeof v === "string" && k !== "값" ? "" : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 비고 칸 고침 — 빈 글이면 칸을 지움 */
|
||||
function withNote(row: Row, text: string): Row {
|
||||
/** 비고 · 별칭 칸 고침 — 빈 글이면 칸을 지움(별칭은 앞뒤 빈칸 뗌) */
|
||||
function withNote(row: Row, text: string, col = NOTE): Row {
|
||||
const out = { ...row };
|
||||
if (text.trim()) out[NOTE] = text;
|
||||
else delete out[NOTE];
|
||||
if (text.trim()) out[col] = col === ALIAS ? text.trim() : text;
|
||||
else delete out[col];
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -117,8 +120,9 @@ export function renderRows(
|
||||
const cols: string[] = [];
|
||||
for (const row of [...adds, ...d.rows]) {
|
||||
for (const k of Object.keys(flatten(row)))
|
||||
if (!hidden(file, k) && k !== NOTE && !cols.includes(k)) cols.push(k);
|
||||
if (!hidden(file, k) && k !== NOTE && k !== ALIAS && !cols.includes(k)) cols.push(k);
|
||||
}
|
||||
const aliased = ALIASED.includes(file);
|
||||
const heads = (HEADS[file] ?? []).map((h) =>
|
||||
el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }),
|
||||
);
|
||||
@@ -127,6 +131,7 @@ export function renderRows(
|
||||
el("th"),
|
||||
...cols.map((c) => el("th", { text: c })),
|
||||
...(JOB.includes(file) ? [] : heads),
|
||||
...(aliased ? [el("th", { text: ALIAS })] : []),
|
||||
el("th", { text: NOTE }),
|
||||
...(JOB.includes(file) ? heads : []),
|
||||
],
|
||||
@@ -145,12 +150,13 @@ export function renderRows(
|
||||
}),
|
||||
);
|
||||
}
|
||||
tr.append(
|
||||
buildCell(show(row[NOTE]), {
|
||||
changed: true,
|
||||
onEdit: (text) => setAdd(file, i, withNote(row, text)),
|
||||
}),
|
||||
);
|
||||
for (const col of aliased ? [ALIAS, NOTE] : [NOTE])
|
||||
tr.append(
|
||||
buildCell(show(row[col]), {
|
||||
changed: true,
|
||||
onEdit: (text) => setAdd(file, i, withNote(row, text, col)),
|
||||
}),
|
||||
);
|
||||
if (JOB.includes(file)) tr.append(el("td"));
|
||||
body.append(tr);
|
||||
});
|
||||
@@ -186,15 +192,16 @@ export function renderRows(
|
||||
? undefined
|
||||
: (next: Row): void => editRow(file, d.version, key, row, next);
|
||||
if (file === LINKED) tr.append(linkedCell(row, cur, put));
|
||||
tr.append(
|
||||
buildCell(show(cur[NOTE]), {
|
||||
changed: !same(cur[NOTE], row[NOTE]),
|
||||
onEdit:
|
||||
deleted || !isScalar(row[NOTE])
|
||||
? undefined
|
||||
: (text) => editRow(file, d.version, key, row, withNote(cur, text)),
|
||||
}),
|
||||
);
|
||||
for (const col of aliased ? [ALIAS, NOTE] : [NOTE])
|
||||
tr.append(
|
||||
buildCell(show(cur[col]), {
|
||||
changed: !same(cur[col], row[col]),
|
||||
onEdit:
|
||||
deleted || !isScalar(row[col])
|
||||
? undefined
|
||||
: (text) => editRow(file, d.version, key, row, withNote(cur, text, col)),
|
||||
}),
|
||||
);
|
||||
if (JOB.includes(file)) tr.append(wageCell(row, cur, put));
|
||||
body.append(tr);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`)
|
||||
* 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직(일위대가 조합은 M02 · PLAN 27-2b)
|
||||
* ─ 구분선 ─ 구조물 옵션 정의(716-1 · 화면 코드는 M02 `StructOptions/` 그대로 · 입구만 여기)
|
||||
* · 별칭 장부(718-3 · 누르면 본 칸에 모인 후보 · 마스터 장부 · `M01_MasterData_UI_Alias`)
|
||||
* · 별칭 모아 보기(815-2 · 누르면 본 칸에 별칭이 적힌 줄 · 승인 대기 · `M01_MasterData_UI_Alias`)
|
||||
* 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
buildOptionsSection,
|
||||
type OptionsSectionOptions,
|
||||
} from "../M02_MasterTemplete/StructOptions/M02_SO_Options";
|
||||
import { ALIAS_TITLE, buildAliasLedger } from "./M01_MasterData_UI_Alias";
|
||||
import { ALIAS_TITLE, buildAliasBoard, type AliasRow } from "./M01_MasterData_UI_Alias";
|
||||
import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree";
|
||||
@@ -504,13 +504,24 @@ ${first.file}`
|
||||
// 구조물 옵션 정의 — 로직 칸 아래 구분선 + 칸 하나(시스템 관리자 화면이라 고침 가능)
|
||||
const options = buildOptionsSection({ ...optionsOpt, isAdmin: true });
|
||||
const optionsSec = section(options.title, options.body);
|
||||
// 별칭 장부(718-3) — 옵션 칸 아래 칸 하나 · 누르면 본 칸(옵션 정의와 같은 자리)
|
||||
// 별칭 모아 보기(815-2) — 옵션 칸 아래 칸 하나 · 누르면 본 칸(옵션 정의와 같은 자리)
|
||||
const aliasSec = section(ALIAS_TITLE, null);
|
||||
aliasSec.classList.add("m01-side__leaf");
|
||||
buttons.set(ALIAS_ID, aliasSec);
|
||||
/** 모아 보기 줄 → 그 요소 화면 그 줄 · 단가산출 기초는 로직 탭 */
|
||||
const openAliasRow = (row: AliasRow): void => {
|
||||
if (row.갈래 === "단가산출 기초") {
|
||||
setPendingLogicKey(row.키);
|
||||
return reveal("로직");
|
||||
}
|
||||
const group = row.갈래 === "유가" ? FUEL_GROUP : row.갈래;
|
||||
jumpToMaster({ kind: "element", group, ref: row.키, file: row.파일 });
|
||||
};
|
||||
const openAlias = async (): Promise<void> => {
|
||||
if (!(await optionsOpt.confirmLeave())) return;
|
||||
optionsOpt.show(ALIAS_TITLE, buildAliasLedger());
|
||||
let setDirty: (dirty: boolean) => void = () => {};
|
||||
const board = buildAliasBoard({ open: openAliasRow, setDirty: (d) => setDirty(d) });
|
||||
setDirty = optionsOpt.show(ALIAS_TITLE, board);
|
||||
options.clear();
|
||||
setActive(ALIAS_ID);
|
||||
};
|
||||
|
||||
@@ -170,3 +170,21 @@ table.m01-master__grid {
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 부품 문서(813-2) — 기계 · 계수 표 아래 */
|
||||
.m01-parts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
margin-top: var(--spacing-16);
|
||||
}
|
||||
.m01-parts__title {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.m01-parts__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
STmate = 원문 그대로 `M02_San_Emit.stmate`(원파일 원문 = 엑셀 숨은 G 열 · 단가 = COSTN ·
|
||||
끝전 = 옵션 SAN_TYP 1 비목버림 / 0 합버림_큰나머지 · 810-1 저장값 대조로 확인).
|
||||
블록 번 = 엑셀 길과 같음(EST = 산근 표 차례 1부터 = 「단산N」 · STmate = 코드 숫자).
|
||||
같은 공사 = 엑셀 산근 블록이 모두 원파일 블록과 금액 · 이름이 같음(`same`) — 둘 다 있으면 원파일.
|
||||
일위대가(EST HeadMainDat) → 실무자료 호표 꼴(`hopyo`) · STmate 일위대가 구성은 못 읽음(BDQTY 난독화) →
|
||||
호표 없음(엑셀을 같이 올리면 엑셀 호표 + 원파일 산근).
|
||||
올리기 = 파일 하나(814-1 · 요구 742-1 — 같은 공사 엑셀 붙이기 걷음) · 원파일 = 단가산출표 판(`grid` ·
|
||||
산출근거 머리) + 산출근거 · 일위대가 안 만듦(요구 743-2 · `hopyo` = 단산만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -50,19 +49,30 @@ def read(name: str, data: bytes) -> dict[str, Any]:
|
||||
def pack(got: dict, name: str) -> dict[str, Any]:
|
||||
"""파서 답 → 둘 꼴(셈 · 변환에 쓰는 칸만 · JSON)."""
|
||||
out: dict[str, Any] = {"꼴": got["꼴"], "파일": name, "표지": got.get("표지") or []}
|
||||
head = ("코드", "이름", "규격", "단위", "저장")
|
||||
out |= {"엑셀": True} if got.get("엑셀") else {} # 812-1 엑셀 읽기기(`M02_San_Xlsx`) — 같은 꼴
|
||||
head = ("코드", "번", "이름", "규격", "단위", "저장")
|
||||
if got.get(
|
||||
"엑셀"
|
||||
): # 엑셀 읽기기 — 줄 행 = 시트 행(원문 카드 · 옛 엑셀 길과 같은 자리) · 블록 표지
|
||||
head += ("표지", "몸", "꼬리")
|
||||
got = {**got, "산근": [_rows(b) for b in got["산근"]]}
|
||||
if got["꼴"] == "EST":
|
||||
out["산근"] = [
|
||||
{**{k: b.get(k) for k in head}, "줄": [_est_line(ln) for ln in b["줄"]]}
|
||||
{**{k: b.get(k) for k in head if k in b}, "줄": [_est_line(ln) for ln in b["줄"]]}
|
||||
for b in got["산근"]
|
||||
]
|
||||
out["일위"] = [{**{k: b.get(k) for k in head}, "줄": b["줄"]} for b in got["일위"]]
|
||||
out["일위"] = [
|
||||
{**{k: b.get(k) for k in head if k in b}, "줄": b["줄"]} for b in got["일위"]
|
||||
]
|
||||
return out
|
||||
out["산근"] = [
|
||||
{**{k: b.get(k) for k in head}, "줄": [{"행": ln["행"], "글": ln["글"]} for ln in b["줄"]]}
|
||||
{
|
||||
**{k: b.get(k) for k in head if k in b},
|
||||
"줄": [{"행": ln["행"], "글": ln["글"]} for ln in b["줄"]],
|
||||
}
|
||||
for b in got["산근"]
|
||||
]
|
||||
out["일위"] = [{k: b.get(k) for k in head} for b in got["일위"]]
|
||||
out["일위"] = [{k: b.get(k) for k in head if k in b} for b in got["일위"]]
|
||||
res = got.get("자원") or {}
|
||||
out["단가"] = {
|
||||
code: {**p, **{k: (res.get(code) or {}).get(k) for k in ("이름", "규격", "단위")}}
|
||||
@@ -72,6 +82,10 @@ def pack(got: dict, name: str) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _rows(b: dict) -> dict:
|
||||
return {**b, "줄": [{**ln, "행": ln.get("엑셀행") or ln["행"]} for ln in b["줄"]]}
|
||||
|
||||
|
||||
def _est_line(ln: dict) -> dict:
|
||||
one = {k: ln.get(k) for k in _EST_KEYS if ln.get(k) not in (None, [], False, "")}
|
||||
if one.get("자원"):
|
||||
@@ -81,9 +95,9 @@ def _est_line(ln: dict) -> dict:
|
||||
|
||||
# ── 블록 ──────────────────────────────────────────────────────────────
|
||||
def numbers(saved: dict) -> dict[int, dict]:
|
||||
"""블록 번 → 산근 블록(엑셀 길 번과 같음)."""
|
||||
"""블록 번 → 산근 블록(엑셀 길 번과 같음 · 엑셀 읽기기 EST = 시트 「N.」 번)."""
|
||||
if saved["꼴"] == "EST":
|
||||
return {i: b for i, b in enumerate(saved["산근"], 1)}
|
||||
return {int(b.get("번") or i): b for i, b in enumerate(saved["산근"], 1)}
|
||||
return {int(str(b["코드"])[1:]): b for b in saved["산근"] if str(b["코드"])[1:].isdigit()}
|
||||
|
||||
|
||||
@@ -91,21 +105,35 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]:
|
||||
"""원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError."""
|
||||
blk = numbers(saved)[int(no)]
|
||||
if saved["꼴"] == "EST":
|
||||
names = {b["코드"]: i for i, b in enumerate(saved["산근"], 1)}
|
||||
names = {b["코드"]: n for n, b in numbers(saved).items()}
|
||||
out = ne.block(blk, int(no), keys, saved["파일"], names)
|
||||
out["원본"] |= {"원코드": blk["코드"]} | ({"규격": blk["규격"]} if blk.get("규격") else {})
|
||||
return out
|
||||
return _excel(saved, out, blk)
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
|
||||
nodes = [{"행": ln["행"], "글": ln["글"], **stm.parse_line(ln["글"])} for ln in blk["줄"]]
|
||||
saved_amt = blk.get("저장") or {}
|
||||
head = {"C": saved_amt.get("T"), "D": saved_amt.get("L"), "E": saved_amt.get("M")}
|
||||
head["F"] = saved_amt.get("S")
|
||||
block = {"코드": blk["코드"], "이름": blk.get("이름"), "몸": [], "꼬리": [], "머리": head}
|
||||
block = {"코드": blk["코드"], "이름": blk.get("이름"), "머리": head}
|
||||
block |= {"몸": blk.get("몸") or [], "꼬리": blk.get("꼬리") or []} # 엑셀 = 줄 금액 · 계 꼬리
|
||||
out = em.stmate(block, nodes, saved["단가"], saved["끝전"], keys, saved["파일"])
|
||||
marks = sum(1 for one in out["원본"]["글줄"] if one.get("표지"))
|
||||
sure = len(out["로직"]["호표"]) + len(out["로직"].get("덧줄") or []) + len(out["로직"]["중간"])
|
||||
out["원본"] |= {"원파일": True, "갈래": "STmate", "확정": {"확정": sure, "표지": marks}}
|
||||
out["원본"] |= (
|
||||
{"규격": blk["규격"]} if blk.get("규격") else {}
|
||||
) # 연결 찾기 근거 · 이름 꼬리(814-1)
|
||||
return _excel(saved, out, blk)
|
||||
|
||||
|
||||
def _excel(saved: dict, out: dict, blk: dict) -> dict[str, Any]:
|
||||
"""엑셀 읽기기 꼴(812-1) — 원본 `원파일` 거짓 · `엑셀` 참(화면 꼬리표 「엑셀」 · 원코드 = 단산 번)."""
|
||||
if saved.get("엑셀"):
|
||||
out["원본"] |= {"원파일": False, "엑셀": True}
|
||||
out["원본"] |= {"시트": "단가산출"} if saved["꼴"] == "EST" else {}
|
||||
out["원본"].pop("원코드", None)
|
||||
out["원본"]["표지"] += [m for m in blk.get("표지") or [] if m.startswith("참고 · ")]
|
||||
return out
|
||||
|
||||
|
||||
@@ -128,89 +156,52 @@ def inner(saved: dict, logic: dict) -> list[tuple[dict, int | None, str | None]]
|
||||
return out
|
||||
|
||||
|
||||
# ── 같은 공사 · 일위대가 ───────────────────────────────────────────────
|
||||
def same(saved: dict, sheets: dict) -> bool:
|
||||
"""엑셀 산근 블록이 모두 원파일 같은 번 블록과 계 금액(1 원 안)이 같음 = 같은 공사."""
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
|
||||
have = numbers(saved)
|
||||
kind = book.kind_of(sheets)
|
||||
if not kind or (kind == "STmate") != (saved["꼴"] == "STmate"):
|
||||
return False
|
||||
if kind == "STmate":
|
||||
excel = {int(b["코드"][1:]): b["머리"].get("C") for b in book.stmate_blocks(sheets)}
|
||||
else:
|
||||
excel = {int(n): t.get("T") for n, t in book.est_prices(sheets)[1].items()}
|
||||
if not excel:
|
||||
return False
|
||||
for n, total in excel.items():
|
||||
blk = have.get(int(n))
|
||||
if blk is None or abs(float(total or 0) - float((blk.get("저장") or {}).get("T") or 0)) > 1:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
GALLAE = {6: "노임", 5: "자재", 105: "기계", 104: "단가산출", 103: "호표", 21: "기타", 101: "기타"}
|
||||
# ── 단가산출표 판(814-1 · 요구 742-2 · 743-2) ───────────────────────────
|
||||
LIST = "단가산출표"
|
||||
_LIST_HEAD = ("코드", "이름", "규격", "단위", "재료비", "노무비", "경비", "합계")
|
||||
_LIST_COST = ("M", "L", "S", "T")
|
||||
|
||||
|
||||
def hopyo(saved: dict) -> dict[str, Any]:
|
||||
"""EST 일위대가(HeadMainDat) → 실무자료 읽기 꼴(`M02_Hopyo_Excel.read_book` 짝 · 호표 · 단산) ·
|
||||
STmate = 호표 없음(구성 못 읽음)."""
|
||||
blocks = numbers(saved)
|
||||
names = {str(b.get("코드")): n for n, b in blocks.items()}
|
||||
heads = {str(h.get("코드")): n for n, h in enumerate(saved.get("일위") or [], 1)}
|
||||
"""원파일 → 실무자료 읽기 꼴 — 단산(산출근거 머리)만 · 일위대가 안 만듦(요구 743-2 · HeadMainDat 안 씀)."""
|
||||
out: dict[str, Any] = {"꼴": "원파일", "시트": saved["꼴"], "호표": [], "검산": {"어긋남": []}}
|
||||
out["단산"] = [
|
||||
{"번": n, "이름": b.get("이름"), "단위": b.get("단위"), "금액": _cost(b.get("저장"))}
|
||||
for n, b in blocks.items()
|
||||
for n, b in numbers(saved).items()
|
||||
]
|
||||
if saved["꼴"] != "EST":
|
||||
return out
|
||||
for n, h in enumerate(saved.get("일위") or [], 1):
|
||||
lines = []
|
||||
for k, ln in enumerate(h.get("줄") or []):
|
||||
link = ln.get("연결")
|
||||
code = str((ln.get("자원") or {}).get("코드") or "")
|
||||
ref = None
|
||||
if link == 104 and code in names:
|
||||
ref = {"종류": "단산", "번": names[code]}
|
||||
elif link == 103 and code in heads:
|
||||
ref = {"종류": "일위", "번": heads[code]}
|
||||
kind = "%" if ln.get("연결") in (52, 58, 62) else GALLAE.get(link or 0, "기타")
|
||||
if kind == "자재" and str(ln.get("단위") or "") == "인":
|
||||
kind = "노임"
|
||||
if not any((ln.get("단가") or {}).values()) and kind not in ("%",):
|
||||
kind = "별도계상" if not ref else kind
|
||||
lines.append(
|
||||
{
|
||||
"차례": k,
|
||||
"이름": ln.get("품명") or "",
|
||||
"규격": ln.get("규격") or "",
|
||||
"수량": _dec(ln.get("수량")),
|
||||
"단위": ln.get("단위") or "",
|
||||
"단가": _cost(ln.get("단가")),
|
||||
"금액": _cost(ln.get("저장")),
|
||||
"비고": "",
|
||||
"참조": ref,
|
||||
"갈래": kind,
|
||||
"행": ln.get("행"),
|
||||
"코드": code or None,
|
||||
}
|
||||
)
|
||||
out["호표"].append(
|
||||
{
|
||||
"번": n,
|
||||
"이름": h.get("이름"),
|
||||
"규격": h.get("규격") or "",
|
||||
"단위": h.get("단위") or "",
|
||||
"금액": _cost(h.get("저장")),
|
||||
"행": [n, n],
|
||||
"줄": lines,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def grid(saved: dict) -> dict[str, Any]:
|
||||
"""원파일 → 단가산출표 한 시트 격자(`M02_Hopyo_Grid.read` 꼴) — 산출근거 머리(코드 · 이름 · 규격 ·
|
||||
단위 · 저장 금액) 한 줄씩 · 행 = 1 머리 + 블록 차례."""
|
||||
cells: dict[str, dict] = {}
|
||||
cols = "ABCDEFGH"
|
||||
for c, head in zip(cols, _LIST_HEAD):
|
||||
cells[f"{c}1"] = {"값": head}
|
||||
rows = []
|
||||
for at, (n, b) in enumerate(numbers(saved).items(), 2):
|
||||
cost = b.get("저장") or {}
|
||||
vals = [b.get("코드"), b.get("이름"), b.get("규격"), b.get("단위")]
|
||||
vals += [_dec(cost.get(k)) for k in _LIST_COST]
|
||||
for c, v in zip(cols, vals):
|
||||
if v not in (None, ""):
|
||||
cells[f"{c}{at}"] = {"값": v if isinstance(v, (int, float)) else str(v)}
|
||||
rows.append({"번": n, "이름": b.get("이름"), "행": [at, at]})
|
||||
sheet = {"id": "s1", "이름": LIST, "칸": cells, "틀고정": {"행": 1}}
|
||||
sheet["열"] = {c: {"폭": w} for c, w in zip(cols, (10, 40, 18, 6, 12, 12, 12, 12))}
|
||||
book = {"종류": "통합문서", "판": 1, "열": "", "서식": [{}], "시트": [sheet], "활성": "s1"}
|
||||
return {
|
||||
"책": book,
|
||||
"호표": [],
|
||||
"단산": rows,
|
||||
"보기": {LIST: "s1"},
|
||||
"원본": {LIST: LIST},
|
||||
"목록": {"호표": [], "단산": [{"번": r["번"], "행": r["행"][0]} for r in rows]},
|
||||
"줄": [],
|
||||
}
|
||||
|
||||
|
||||
def _dec(v: Any) -> int | float | None:
|
||||
"""수 → 엑셀 읽기와 같은 꼴(정수 = int · 아니면 float · 없음 = None)."""
|
||||
if v is None:
|
||||
|
||||
@@ -193,10 +193,9 @@ def hourly_link(eq: str, part: str) -> dict | None:
|
||||
if part not in imp.COSTS or not imp.hourly_of(eq).get("ok"):
|
||||
return None
|
||||
code = str(imp._whole().get(eq).get("원문번호") or "")
|
||||
fit = [r for r in machine._family() if code in [str(c) for c in machine._codes(r)]]
|
||||
if not fit:
|
||||
logic = machine.logic_of(code)
|
||||
if not logic:
|
||||
return None
|
||||
logic = min(fit, key=machine._rank)
|
||||
given = {"기계": _lit(code)}
|
||||
for spec in logic.get("입력") or []:
|
||||
if spec.get("이름") != "기계":
|
||||
@@ -226,6 +225,8 @@ def _link_of(item: dict) -> tuple[dict | None, str]:
|
||||
}
|
||||
return link | ({"칸": item["참조비목"]} if item.get("참조비목") else {}), ""
|
||||
key = str(item.get("이음") or "")
|
||||
if not key and (item.get("자원") or {}).get("조합식"):
|
||||
return None, f"우리 조합 식 {item['자원']['조합식']} 에 듦(첫 줄) — 이 줄 값 0"
|
||||
if not key:
|
||||
return None, ""
|
||||
if item.get("종류") == "로직":
|
||||
@@ -233,12 +234,74 @@ def _link_of(item: dict) -> tuple[dict | None, str]:
|
||||
if key.startswith("EQ"):
|
||||
got = hourly_link(key, str(item.get("비목") or ""))
|
||||
return got, "" if got else f"기계 {key} 시간당 경비 자료 없음 — 엑셀 단가 그대로"
|
||||
if key.startswith("MO") and (got := _oil(key)):
|
||||
return got, ""
|
||||
link = {"갈래": "요소", "키": key}
|
||||
if key[:2] == dc.cv.mm.LINKED: # 품셈재료 = 줄 단위로 값이 갈림(M01 변환과 같음)
|
||||
link["단위"] = str(item.get("단위") or "")
|
||||
return link, ""
|
||||
|
||||
|
||||
OPTION = "_옵션" # 연결이 부르는 옵션 변수(문서에 없으면 `_attach` 가 더함)
|
||||
|
||||
|
||||
def _oil(key: str) -> dict | None:
|
||||
"""유가 · 전력 이음(814-1 · 요구 748) — 우리 기초 꼴: 유가 = `MO:경유:{S_지역}` + 지역 옵션(기본
|
||||
전국평균) · 전력 = 전력량요금 계약종별 고르기 + 계약종별 옵션(기본 일반용(갑)Ⅰ 저압)."""
|
||||
whole = imp._whole()
|
||||
try:
|
||||
row = whole.get(key)
|
||||
except Exception: # noqa: BLE001 — 없는 키 = 보통 요소 연결
|
||||
return None
|
||||
rows = list(whole.index.get("MO", {}).values())
|
||||
if row.get("구분") == "전력":
|
||||
kinds = list(dict.fromkeys(str(r.get("상세구분")) for r in rows if r.get("구분") == "전력" and r.get("이름") == row.get("이름"))) # fmt: skip
|
||||
var = _choice("전력 계약종별", "전력계약종별", kinds, str(row.get("상세구분")))
|
||||
link = {
|
||||
"갈래": "요소",
|
||||
"재료": {"이름": row.get("이름"), "계약종별": "입력"},
|
||||
"단위": "㎾h",
|
||||
}
|
||||
return link | {"계약종별": dc.md.var_id(var), OPTION: var}
|
||||
head = str(row.get("원문번호") or "").split(":")[0]
|
||||
if not head or row.get("구분") != "유류":
|
||||
return None
|
||||
places = [
|
||||
str(r.get("상세구분")) for r in rows if str(r.get("원문번호") or "").split(":")[0] == head
|
||||
]
|
||||
places.sort(key=lambda x: x != "전국평균")
|
||||
var = _choice("지역", "지역", places, str(row.get("상세구분")))
|
||||
return {"갈래": "요소", "키": f"MO:{head}:{{{dc.md.var_id(var)}}}", OPTION: var}
|
||||
|
||||
|
||||
def _choice(key: str, below: str, items: list[str], value: str) -> dict:
|
||||
link = {"갈래": "선택", "목록": items, "값": value}
|
||||
return {
|
||||
"열쇠": key,
|
||||
"기호": "S",
|
||||
"아래": below,
|
||||
"위": "",
|
||||
"이름": key,
|
||||
"단위": "",
|
||||
"연결": link,
|
||||
}
|
||||
|
||||
|
||||
MACHINES = "_기계" # 문서 셈 동안 잠깐 — `derive` 가 머리 `가져온.기계` 로 옮김
|
||||
|
||||
|
||||
def _machine_entry(item: dict, sym: str) -> dict:
|
||||
"""기계 줄 찾기 판정 한 칸 — {기호, 원래 {이름, 규격, 단위}, 판정, 키, 후보, 찾기어, 까닭, 조합?}."""
|
||||
res = item["자원"]
|
||||
got = res["찾기"]
|
||||
one = {
|
||||
"기호": sym,
|
||||
"원래": {"이름": got["이름"], "규격": got["규격"], "단위": item.get("단위")},
|
||||
}
|
||||
one |= {k: got.get(k) for k in ("판정", "키", "후보", "찾기어", "까닭")}
|
||||
return one | {k: True for k in ("조합", "조합첫") if res.get(k)}
|
||||
|
||||
|
||||
def _excel(item: dict) -> Any:
|
||||
if item.get("값묶음"):
|
||||
return {k: Decimal(str(item["값묶음"].get(k) or 0)) for k in imp.COSTS}
|
||||
@@ -246,12 +309,16 @@ def _excel(item: dict) -> Any:
|
||||
|
||||
|
||||
def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
|
||||
"""금액 줄 단가 숫자(엑셀 값) → 이은 연결 + 엑셀 값 기억(SK 고침으로 바뀐 값 = 숫자 그대로)."""
|
||||
"""금액 줄 단가 숫자(엑셀 값) → 이은 연결 + 엑셀 값 기억(SK 고침으로 바뀐 값 = 숫자 그대로) ·
|
||||
기계 찾기 판정(812-9) = 문서 `가져온.기계`(단가 기호마다 · 화면 고르기 · 저장 때 별칭)."""
|
||||
by_id = {dc.md.var_id(v): v for v in doc.get("변수") or []}
|
||||
found: list[dict] = doc.setdefault(MACHINES, [])
|
||||
for at, line in enumerate((doc.get("금액") or [])[: len(row["호표"])]):
|
||||
item = src.get(ids[at]) if at < len(ids) else None
|
||||
price = str(line.get("단가") or "")
|
||||
var = by_id.get(price.removesuffix(".계"))
|
||||
if item is not None and var is not None and (item.get("자원") or {}).get("찾기"):
|
||||
found.append(_machine_entry(item, dc.md.var_id(var)))
|
||||
if item is None or var is None or (var.get("연결") or {}).get("갈래") != "숫자":
|
||||
continue
|
||||
excel = _excel(item)
|
||||
@@ -261,7 +328,18 @@ def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
|
||||
if note:
|
||||
var["설명"] = note
|
||||
if link is None:
|
||||
if not note and not item.get("이음"): # 못 이은 까닭(814-1 — 흠 아님 · 우리 정보 없음)
|
||||
from M02_MasterTemplete import M02_San_Link as san_link
|
||||
|
||||
why = san_link.why(item)
|
||||
var["설명"] = (
|
||||
why if why == san_link.QUOTE else f"이음 없음 — {why} · 엑셀 단가 그대로"
|
||||
)
|
||||
continue
|
||||
if (opt := link.pop(OPTION, None)) and opt["열쇠"] not in {
|
||||
v.get("열쇠") for v in doc["변수"]
|
||||
}:
|
||||
doc["변수"].append(opt) # 유가 지역 · 전력 계약종별 옵션(문서에 하나)
|
||||
if item.get("참조비목") and isinstance(excel, dict):
|
||||
excel = excel[item["참조비목"]]
|
||||
line["단가"], line["비목"] = dc.md.var_id(var), item["참조비목"]
|
||||
@@ -304,11 +382,16 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
|
||||
doc, notes = dc.cv.convert(_Row(master, key, row), row, given)
|
||||
_attach(doc, row, ids, src)
|
||||
_numbers(doc, keep)
|
||||
if (meta.get("원본") or {}).get("원파일"): # 원파일(810-2) — 소계 비목 칸 비움 · 값 변수 출처
|
||||
_native(doc)
|
||||
native = bool((meta.get("원본") or {}).get("원파일"))
|
||||
if native or (meta.get("원본") or {}).get(
|
||||
"엑셀"
|
||||
): # 810-2 · 812-1 — 소계 비목 칸 비움 · 값 변수 출처
|
||||
_native(doc, native)
|
||||
head = {k: (meta.get("원본") or {}).get(k) for k in HEAD if (meta.get("원본") or {}).get(k)}
|
||||
marks = list((meta.get("원본") or {}).get("표지") or [])
|
||||
if (meta.get("원본") or {}).get("원파일"): # 810-3 — 품셈 조건 글(값 없음) = 화면 「추정」 칸
|
||||
if native or (meta.get("원본") or {}).get(
|
||||
"엑셀"
|
||||
): # 810-3 — 품셈 조건 글(값 없음) = 「추정」 칸
|
||||
head["추정"] = [
|
||||
{"행": x.get("행"), "글": " ".join(str(x.get("글") or "").split())}
|
||||
for x in meta["원본"].get("글줄") or []
|
||||
@@ -317,18 +400,24 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
|
||||
if not (doc.get("금액") or doc.get("결과") or row["호표"] or row.get("덧줄")):
|
||||
_empty(doc) # 빈 산근 = 엑셀 0 원(줄 없음 합) — 「—」 아님
|
||||
marks.append(EMPTY)
|
||||
found = doc.pop(MACHINES, None)
|
||||
head |= {"기계": found} if found else {} # 812-9 기계 찾기 판정(줄 쌓기 고르기 · 저장 때 별칭)
|
||||
doc["가져온"] = head | {"표지": marks} | ({"알림": notes} if notes else {})
|
||||
shape.shape(doc, meta) # 809-1 · 요구 704 — 지은 이름 → 엑셀 글 머리 · EST 기계 세 줄 = 한 줄
|
||||
if found: # 세 줄을 한 줄로 합쳐 사라진 기호의 기계 판정은 뺌
|
||||
alive = {dc.md.var_id(v) for v in doc.get("변수") or []}
|
||||
doc["가져온"]["기계"] = [x for x in found if x["기호"] in alive]
|
||||
return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))}
|
||||
|
||||
|
||||
def _native(doc: dict) -> None:
|
||||
"""원파일 문서 — 소계 덧줄(합 밖) 비목 칸 비움(금액만) · 수 하나 변수(옵션 칸) 출처 「원파일」."""
|
||||
def _native(doc: dict, native: bool = True) -> None:
|
||||
"""원파일 · 엑셀 읽기기 문서 — 소계 덧줄(합 밖) 비목 칸 비움(금액만) · 수 하나 변수(옵션 칸) 출처
|
||||
「원파일」(엑셀 = 출처 없음 → 화면 꼬리표 「엑셀」)."""
|
||||
for line in doc.get("금액") or []:
|
||||
if line.get("출처") == SUB and line.get("합밖"):
|
||||
line.pop("비목", None)
|
||||
for var in doc.get("변수") or []:
|
||||
if (var.get("연결") or {}).get("갈래") == "숫자" and XL not in var["연결"]:
|
||||
if native and (var.get("연결") or {}).get("갈래") == "숫자" and XL not in var["연결"]:
|
||||
var["출처"] = "원파일"
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
_MADE = re.compile(r"^(?:줄|노무비|재료비|경비|계|소계|합계)?_\d+$")
|
||||
# 지은 이름 — 「줄_2 · 노무비_2」 · 읽기기 율 줄 「율」
|
||||
_MADE = re.compile(r"^(?:(?:줄|노무비|재료비|경비|계|소계|합계)?_\d+|율(?:_\d+)?)$")
|
||||
_SECTION = re.compile(r"^\s*\d+\s*\.\s*(.+?)\s*$")
|
||||
|
||||
|
||||
@@ -68,20 +69,26 @@ def _rename_steps(doc: dict, swap: dict[str, str]) -> None:
|
||||
st["줄"] = got
|
||||
|
||||
|
||||
def _bundle_of(doc: dict, at: int) -> list[int] | None:
|
||||
"""at 에서 시작하는 기계 세 줄(노무비 · 재료비 · 경비) — 같은 기계 이음 · 같은 수량."""
|
||||
def _bundle_of(doc: dict, at: int, who: dict[str, str] | None = None) -> list[int] | None:
|
||||
"""at 에서 시작하는 기계 세 줄(노무비 · 재료비 · 경비 · 차례 아무렇게) — 같은 기계 이음 · 같은 수량 ·
|
||||
시간당 경비 이음 없음(숫자)이면 같은 기계 자원(`who` 줄 이름 → 자원 · 814-1)."""
|
||||
money = doc.get("금액") or []
|
||||
three = money[at : at + 3]
|
||||
if len(three) < 3 or [x.get("비목") for x in three] != list(COSTS):
|
||||
if len(three) < 3 or sorted(str(x.get("비목")) for x in three) != sorted(COSTS):
|
||||
return None
|
||||
vars_ = {v.get("열쇠"): v for v in doc.get("변수") or []}
|
||||
links = [(vars_.get(str(x.get("단가"))) or {}).get("연결") or {} for x in three]
|
||||
if any(x.get("갈래") != "단가산출" or "기계" not in (x.get("입력") or {}) for x in links):
|
||||
if all(x.get("갈래") == "숫자" and not isinstance(x.get("값"), dict) for x in links):
|
||||
mine = {(who or {}).get(str(x.get("이름"))) for x in three}
|
||||
if len(mine) != 1 or None in mine:
|
||||
return None
|
||||
elif any(x.get("갈래") != "단가산출" or "기계" not in (x.get("입력") or {}) for x in links):
|
||||
return None
|
||||
same = {(x.get("키"), str(x.get("입력"))) for x in links}
|
||||
if len(same) != 1 or [x.get("칸") for x in links] != list(COSTS):
|
||||
elif len({(x.get("키"), str(x.get("입력"))) for x in links}) != 1 or [
|
||||
x.get("칸") for x in links
|
||||
] != [x.get("비목") for x in three]:
|
||||
return None
|
||||
for k in ("수량", "규격", "자리", "합밖"):
|
||||
for k in ("수량", "규격", "자리", "합밖", "번지"):
|
||||
if len({str(x.get(k) or "") for x in three}) != 1:
|
||||
return None
|
||||
names = {str(x.get("이름")) for x in three}
|
||||
@@ -113,30 +120,70 @@ def _rewrite(text: Any, swap: dict[str, str]) -> Any:
|
||||
return pat.sub(lambda m: swap[m.group(0)], text)
|
||||
|
||||
|
||||
def machines(doc: dict, title: dict[str, str]) -> None:
|
||||
"""② EST 기계 세 줄 → 한 기계 줄(비목 묶음) · 부르던 식 = 비목 칸. `title` = {줄 이름: 자원 이름}."""
|
||||
AFTER = "_뒤" # 옮긴 조합 다음 줄 — 차림이 원본 행 차례로 다시 섞지 않게(다듬기 사이 임시)
|
||||
|
||||
|
||||
def regroup(doc: dict, who: dict[str, str], parts: set[str]) -> None:
|
||||
"""814-1 — 조합 기계 비목 세 줄 사이에 낀 다음 줄(값 0)을 세 줄 뒤로 · 같은 기계끼리 모음
|
||||
(첫 줄 · 다음 줄이 엇갈려 항목이 겹치지 않게 · 값 0 줄이라 셈 그대로). `parts` = 조합 다음 줄 이름."""
|
||||
money = doc.get("금액") or []
|
||||
at = 0
|
||||
while at < len(money):
|
||||
head = who.get(str(money[at].get("이름")))
|
||||
if head is None or str(money[at].get("이름")) in parts:
|
||||
at += 1
|
||||
continue
|
||||
end, mains, rest = at, [], []
|
||||
while end < len(money):
|
||||
name = str(money[end].get("이름"))
|
||||
if name in parts:
|
||||
rest.append(money[end])
|
||||
elif who.get(name) == head and len(mains) < 3:
|
||||
mains.append(money[end])
|
||||
else:
|
||||
break
|
||||
end += 1
|
||||
if len(mains) > 1 and rest:
|
||||
seen = list(dict.fromkeys(who.get(str(x.get("이름"))) for x in rest))
|
||||
rest.sort(key=lambda x: seen.index(who.get(str(x.get("이름"))))) # 같은 기계끼리
|
||||
for x in rest:
|
||||
x[AFTER] = True # 차림(`NativeLayout.apply`) 원본 행 = 앞 줄 행 뒤
|
||||
money[at:end] = mains + rest
|
||||
at = max(end, at + 1)
|
||||
|
||||
|
||||
def machines(doc: dict, title: dict[str, str], who: dict[str, str] | None = None) -> None:
|
||||
"""② 기계 세 줄 → 한 기계 줄(비목 묶음) · 부르던 식 = 비목 칸. `title` = {줄 이름: 자원 이름} ·
|
||||
`who` = {줄 이름: 기계 자원 열쇠}(시간당 경비 이음 없는 세 줄 · 814-1)."""
|
||||
money = doc.get("금액") or []
|
||||
vars_ = {v.get("열쇠"): v for v in doc.get("변수") or []}
|
||||
out, swap, at, used = [], {}, 0, set()
|
||||
while at < len(money):
|
||||
idx = _bundle_of(doc, at)
|
||||
idx = _bundle_of(doc, at, who)
|
||||
if idx is None:
|
||||
out.append(money[at])
|
||||
at += 1
|
||||
continue
|
||||
three = [money[i] for i in idx]
|
||||
first = vars_[str(three[0]["단가"])]
|
||||
link = {k: v for k, v in first["연결"].items() if k not in ("칸", "엑셀")}
|
||||
excel = {
|
||||
c: Decimal(str((vars_[str(x["단가"])]["연결"]).get("엑셀") or 0))
|
||||
for c, x in zip(COSTS, three)
|
||||
}
|
||||
link["엑셀"] = excel
|
||||
name = title.get(str(three[0].get("이름"))) or str(three[0].get("이름"))
|
||||
three = sorted((money[i] for i in idx), key=lambda x: COSTS.index(x["비목"]))
|
||||
first = vars_[str(money[idx[0]]["단가"])]
|
||||
if first["연결"].get("갈래") == "숫자": # 시간당 경비 이음 없음 = 엑셀 값 묶음
|
||||
link = {
|
||||
"갈래": "숫자",
|
||||
"값": {c: (vars_[str(x["단가"])]["연결"]).get("값") for c, x in zip(COSTS, three)},
|
||||
}
|
||||
else:
|
||||
link = {k: v for k, v in first["연결"].items() if k not in ("칸", "엑셀")}
|
||||
link["엑셀"] = {
|
||||
c: Decimal(str((vars_[str(x["단가"])]["연결"]).get("엑셀") or 0))
|
||||
for c, x in zip(COSTS, three)
|
||||
}
|
||||
lead = money[idx[0]]
|
||||
name = title.get(str(lead.get("이름"))) or str(lead.get("이름"))
|
||||
first["연결"], first["이름"] = link, f"{name} 단가"
|
||||
if name in {str(x.get("이름")) for x in money} - {str(x.get("이름")) for x in three}:
|
||||
name = f"{name} ({at + 1})" # 같은 이름 줄이 있으면 겹치지 않게
|
||||
line = {k: v for k, v in three[0].items() if k != "기호"} | {"이름": name, "비목": "묶음"}
|
||||
line = {k: v for k, v in lead.items() if k != "기호"} | {"이름": name, "비목": "묶음"}
|
||||
line["단가"] = lead["단가"]
|
||||
_rename_steps(doc, {str(x.get("이름")): name for x in three})
|
||||
if any(x.get("기호") for x in three): # 부르던 식 = 번지 칸(엔진 `번지.X.노무비`)
|
||||
place = _place(doc, name, used)
|
||||
@@ -147,7 +194,8 @@ def machines(doc: dict, title: dict[str, str]) -> None:
|
||||
doc["변수"] = [
|
||||
v
|
||||
for v in doc["변수"]
|
||||
if v is first or v.get("열쇠") not in {str(x["단가"]) for x in three[1:]}
|
||||
if v is first
|
||||
or v.get("열쇠") not in {str(x["단가"]) for x in three} - {str(lead["단가"])}
|
||||
]
|
||||
out.append(line)
|
||||
at += 3
|
||||
@@ -166,11 +214,18 @@ def shape(doc: dict, meta: dict) -> dict:
|
||||
src = meta.get("원본") or {}
|
||||
lay.tag(doc, meta) # 812-3 원파일 차림 — 금액 줄 ↔ 로직 줄(끝에 `apply`)
|
||||
names(doc, list(src.get("글줄") or []))
|
||||
title = {}
|
||||
title, who, parts = {}, {}, set()
|
||||
for item in (meta.get("로직") or {}).get("호표") or []:
|
||||
res = item.get("자원") or {}
|
||||
if res.get("갈래") == "기계" and res.get("이름"):
|
||||
title[str(item.get("이름"))] = " ".join(x for x in (res["이름"], res.get("규격")) if x)
|
||||
machines(doc, title)
|
||||
key = (res.get("코드"), res.get("이름"), res.get("규격"))
|
||||
who[str(item.get("이름"))] = repr(key)
|
||||
if res.get("조합"):
|
||||
parts.add(str(item.get("이름")))
|
||||
regroup(doc, who, parts)
|
||||
machines(doc, title, who)
|
||||
lay.apply(doc, meta)
|
||||
for line in doc.get("금액") or []:
|
||||
line.pop(AFTER, None)
|
||||
return doc
|
||||
|
||||
@@ -1,622 +0,0 @@
|
||||
"""가져온 식 ↔ 우리 M01 기초 연결 찾기(PLAN 810-4 · 요구 707 · 709 · 703) — 서버 단독.
|
||||
|
||||
가져온 식 문서(원파일 확정 정보 · 우리 단가로 셈) 하나를 M01 수식 문서 전부와 맞대어
|
||||
「확정」 · 「근접」 · 「없음」 을 냄 — 지어 맞추기 없음(값 보정 · 허용 오차 없음 · 원 단위 같아야 확정).
|
||||
① 후보 = 결과단위 같음 + 노임 키 ⊂ M01 노임 키 + 재료 · 기계 있으면 M01 에도 ·
|
||||
품셈 절 번호(가져온 「추정」 글 · M01 `출처`)가 겹치는 문서가 있으면 그것만.
|
||||
② 옵션 풀이 = 그 문서의 선택 · 입력 변수 값을 줄마다 좁힘(줄 수량 · 단가가 의존하는 변수만 돌림 ·
|
||||
값이 가져온 줄 수량 · 단가 묶음에 있거나 0(꺼진 줄)인 값만 남김) → 남은 조합(상한 `CAP_ALL`)을
|
||||
문서 셈 · 입력 변수 후보 = 기본값 · 0 · 가져온 식에 나온 수 · 선택은 가져온 글에 든 값부터.
|
||||
③ 판정 = 줄 대조(수량 · 단가 · 비목 금액 · 율 줄) + 노 · 재 · 경 · 계 —
|
||||
다른 항 0 = 「확정」 · 같은 줄이 하나 넘고 핵심 줄 반 넘게 짝 = 「근접」(다른 항 목록) · 그 밖 「없음」.
|
||||
결과 `{판정, 키, 이름, 옵션, 다른, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
|
||||
md, mc, ml = dc.md, dc.mc, dc.ml
|
||||
if not hasattr(
|
||||
md.parse, "cache_info"
|
||||
): # 식 글 → 나무 = 순수 함수 · 조합마다 같은 글을 다시 풀지 않음
|
||||
md.parse = functools.lru_cache(maxsize=50000)(md.parse)
|
||||
|
||||
KEY = "연결후보"
|
||||
SURE, NEAR, NONE = "확정", "근접", "없음"
|
||||
CAP_LINE = 3000 # 줄 하나 좁힐 때 돌리는 조합 상한(넘으면 그 줄은 좁힘에 안 씀)
|
||||
CAP_ALL = 192 # 문서 하나 끝 셈 조합 상한
|
||||
CAP_INPUT = 12 # 입력 변수 후보 수 상한
|
||||
NICE = (2, 3, 4, 5, 6, 8, 10, 12, 100, 1000) # 단위 환산으로 보는 배
|
||||
_SEC = re.compile(r"(?<![\d-])(\d{1,2}(?:-\d{1,2}){1,3})(?![\d-])")
|
||||
_NUMS = re.compile(r"\d+(?:\.\d+)?")
|
||||
_UNIT = {"m3": "㎥", "m2": "㎡", "m": "m", "ea": "개", "톤": "ton", "t": "ton", "hr": "시간"}
|
||||
|
||||
|
||||
# ── 수 ────────────────────────────────────────────────────────────────
|
||||
def _key(v: Any) -> Any:
|
||||
"""비교 열쇠 — 수 = 유효 12 자리(Decimal 셈 자리 차이만 지움) · 묶음 = 비목 튜플."""
|
||||
if isinstance(v, dict):
|
||||
return tuple(_key(v.get(k)) for k in md.COSTS)
|
||||
if isinstance(v, Decimal):
|
||||
try:
|
||||
return v.normalize() if v == 0 else Decimal(f"{v:.12g}").normalize()
|
||||
except InvalidOperation:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def unit_of(text: Any) -> str:
|
||||
"""결과단위 「원/㎡」 → 「㎡」(소문자 · m3 → ㎥ 등) · 돈 아닌 단위 = ""."""
|
||||
text = str(text or "").strip()
|
||||
if not text.startswith("원/"):
|
||||
return ""
|
||||
unit = text[2:].strip().lower().replace(" ", "")
|
||||
return _UNIT.get(unit, unit)
|
||||
|
||||
|
||||
def sections(texts: list[str]) -> set[str]:
|
||||
"""품셈 절 번호(「6-3-1」) — 앞 둘 · 셋 마디 모두."""
|
||||
out = set()
|
||||
for t in texts:
|
||||
for s in _SEC.findall(str(t or "")):
|
||||
parts = s.split("-")
|
||||
out |= {"-".join(parts[:n]) for n in range(2, len(parts) + 1)}
|
||||
return out
|
||||
|
||||
|
||||
# ── 줄 사실 ───────────────────────────────────────────────────────────
|
||||
def _single(text: Any) -> str | None:
|
||||
try:
|
||||
node = md.parse(text)
|
||||
except md.DocError:
|
||||
return None
|
||||
return node[1] if node[0] == "var" else None
|
||||
|
||||
|
||||
def _what(doc: dict, line: dict) -> tuple[str, str]:
|
||||
"""금액 줄 자원 → (갈래, 코드) · 갈래 = 노임 · 재료 · 기계 · 산출 · 율 · 수."""
|
||||
sym = _single(line.get("단가"))
|
||||
var = next((v for v in doc.get("변수") or [] if sym and md.var_id(v) == sym), None)
|
||||
if var is None:
|
||||
return "율", ""
|
||||
link = var.get("연결") or {}
|
||||
kind, key = link.get("갈래"), str(link.get("키") or "")
|
||||
if kind == "요소":
|
||||
if key.startswith("LB"):
|
||||
return "노임", key
|
||||
if key.startswith("EQ"):
|
||||
return "기계", key
|
||||
return "재료", key
|
||||
if kind == "단가산출":
|
||||
given = link.get("입력") or {}
|
||||
code = str(given.get("기계") or "").strip("'")
|
||||
return ("기계", code) if "기계" in given else ("산출", key)
|
||||
if kind == "숫자" and isinstance(link.get("값"), dict):
|
||||
return "산출", ""
|
||||
if kind == "식":
|
||||
return "율", ""
|
||||
return "수", ""
|
||||
|
||||
|
||||
def facts(doc: dict, got: dict) -> list[dict]:
|
||||
"""셈 답 줄 → 대조 사실 — 소계 보임(비목 없는 합 밖) · 꺼진 줄(수량 0 · 금액 0) 뺌."""
|
||||
out = []
|
||||
money = doc.get("금액") or []
|
||||
for i, (line, row) in enumerate(zip(money, got.get("줄") or [])):
|
||||
if line.get("합밖") and not line.get("비목"):
|
||||
continue
|
||||
if row.get("금액") is not None and row["금액"] == 0 and row.get("수량") == 0:
|
||||
continue
|
||||
kind, code = _what(doc, line)
|
||||
if kind == "기계" and code in (got.get("값") or {}): # 옵션 기호 = 그 값(기계 코드)
|
||||
code = mc.plain_text(got["값"][code])
|
||||
if kind == "율" or (kind == "수" and line.get("수량") in (None, "")):
|
||||
kind = "율"
|
||||
out.append(
|
||||
{
|
||||
"번": i,
|
||||
"이름": str(line.get("이름") or ""),
|
||||
"갈래": kind,
|
||||
"코드": code,
|
||||
"수량": _key(row.get("수량")) if kind != "율" else None,
|
||||
"단가": _key(row.get("단가")) if kind != "율" else None,
|
||||
"비목": {k: _key(v) for k, v in (row.get("비목") or {}).items() if v},
|
||||
"금액": _key(row.get("금액")),
|
||||
"합밖": bool(row.get("합밖")),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def resources(doc: dict) -> dict[str, set]:
|
||||
"""문서가 쓸 수 있는 자원(옵션 무관) — {노임 키, 재료, 기계}."""
|
||||
out: dict[str, set] = {"노임": set(), "재료": set(), "기계": set()}
|
||||
for line in doc.get("금액") or []:
|
||||
kind, code = _what(doc, line)
|
||||
if kind == "노임":
|
||||
out["노임"].add(code)
|
||||
elif kind == "재료":
|
||||
out["재료"].add(code or "고름")
|
||||
elif kind in ("기계", "산출"):
|
||||
out["기계"].add(code or kind)
|
||||
return out
|
||||
|
||||
|
||||
# ── 연결 기억 ─────────────────────────────────────────────────────────
|
||||
class Memo:
|
||||
"""M01 연결 + 셈 기억(요소 · 표 · 부른 단가산출) — 같은 값을 조합마다 다시 찾지 않음."""
|
||||
|
||||
def __init__(self, links: Any):
|
||||
self.links, self.hits, self.calls = links, {}, {}
|
||||
|
||||
def _hit(self, tag: str, p: dict, fn: Any) -> Any:
|
||||
key = (tag, json.dumps(p, sort_keys=True, ensure_ascii=False, default=str))
|
||||
if key not in self.hits:
|
||||
try:
|
||||
self.hits[key] = (True, fn(p))
|
||||
except (mc.Gap, mc.Broken) as e:
|
||||
self.hits[key] = (False, e)
|
||||
ok, got = self.hits[key]
|
||||
if not ok:
|
||||
raise got
|
||||
return got
|
||||
|
||||
def element(self, p: dict) -> Any:
|
||||
return self._hit("요소", p, self.links.element)
|
||||
|
||||
def table(self, p: dict) -> Any:
|
||||
return self._hit("표", p, self.links.table)
|
||||
|
||||
def doc(self, key: str) -> dict | None:
|
||||
return self.links.doc(key)
|
||||
|
||||
|
||||
# ── ① 후보 ────────────────────────────────────────────────────────────
|
||||
class Index:
|
||||
"""M01 수식 문서 색인(결과단위 · 자원 · 절) — 프로세스 안 한 번."""
|
||||
|
||||
def __init__(self, docs: dict[str, dict]):
|
||||
self.docs = docs
|
||||
self.by_unit: dict[str, list[str]] = {}
|
||||
self.res: dict[str, dict[str, set]] = {}
|
||||
self.secs: dict[str, set[str]] = {}
|
||||
self.regions: set[str] = set()
|
||||
for key, doc in docs.items():
|
||||
for v in doc.get("변수") or []:
|
||||
if str(v.get("열쇠") or "").endswith("지역"):
|
||||
self.regions |= {str(x) for x in (v.get("연결") or {}).get("목록") or []}
|
||||
unit = unit_of(doc.get("결과단위"))
|
||||
if not unit or not doc.get("금액"):
|
||||
continue
|
||||
self.by_unit.setdefault(unit, []).append(key)
|
||||
self.res[key] = resources(doc)
|
||||
self.secs[key] = sections([doc.get("출처")])
|
||||
|
||||
def candidates(self, doc: dict) -> list[str]:
|
||||
mine = resources(doc)
|
||||
keys = []
|
||||
for key in self.by_unit.get(unit_of(doc.get("결과단위")), []):
|
||||
have = self.res[key]
|
||||
if not mine["노임"] <= have["노임"]:
|
||||
continue
|
||||
if mine["재료"] and not have["재료"]:
|
||||
continue
|
||||
if mine["기계"] and not have["기계"]:
|
||||
continue
|
||||
if not (mine["노임"] or mine["재료"] or mine["기계"]):
|
||||
continue
|
||||
keys.append(key)
|
||||
secs = sections([x.get("글") for x in (doc.get("가져온") or {}).get("추정") or []])
|
||||
hit = [k for k in keys if secs & self.secs[k]]
|
||||
return hit or keys
|
||||
|
||||
|
||||
# ── ② 옵션 풀이 ───────────────────────────────────────────────────────
|
||||
def _closure(doc: dict) -> dict[str, set[str]]:
|
||||
"""기호 → 그 기호가 (거쳐서) 부르는 이름 전부."""
|
||||
direct: dict[str, set[str]] = {}
|
||||
for v in doc.get("변수") or []:
|
||||
direct[md.var_id(v)] = set(md.link_names(v.get("연결") or {}))
|
||||
for line in doc.get("식") or []:
|
||||
try:
|
||||
direct[str(line.get("기호"))] = set(md.names(md.parse(line.get("식"))))
|
||||
except md.DocError:
|
||||
direct[str(line.get("기호"))] = set()
|
||||
memo: dict[str, set[str]] = {}
|
||||
|
||||
def reach(name: str, seen: tuple = ()) -> set[str]:
|
||||
if name in memo:
|
||||
return memo[name]
|
||||
out = set()
|
||||
for n in direct.get(name, ()):
|
||||
if n not in seen:
|
||||
out |= {n} | reach(n, (*seen, name))
|
||||
memo[name] = out
|
||||
return out
|
||||
|
||||
return {n: reach(n) for n in direct}
|
||||
|
||||
|
||||
def _uses(text: Any, close: dict) -> set[str] | None:
|
||||
try:
|
||||
names = md.names(md.parse(text)) if text not in (None, "") else []
|
||||
except md.DocError:
|
||||
return None
|
||||
out = set(names)
|
||||
for n in names:
|
||||
out |= close.get(n, set())
|
||||
return out
|
||||
|
||||
|
||||
def numbers_of(doc: dict) -> list[Decimal]:
|
||||
"""가져온 식에 나온 수(숫자 변수 · 식 · 수량 글 · 규격 · 이름)."""
|
||||
got: list[Decimal] = []
|
||||
texts = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")]
|
||||
for v in doc.get("변수") or []:
|
||||
val = (v.get("연결") or {}).get("값")
|
||||
if not isinstance(val, dict):
|
||||
texts.append(val)
|
||||
texts += [x.get("식") for x in doc.get("식") or []]
|
||||
texts += [x.get("수량") for x in doc.get("금액") or []]
|
||||
texts += [x.get("규격") for x in doc.get("금액") or []]
|
||||
for t in texts:
|
||||
for s in _NUMS.findall(str(t or "")):
|
||||
d = Decimal(s).normalize()
|
||||
if d not in got:
|
||||
got.append(d)
|
||||
return got
|
||||
|
||||
|
||||
def _words(doc: dict) -> str:
|
||||
"""가져온 식 글(이름 · 규격 · 줄 규격 · 부름 입력 값 — 줄 이름 뺌) — 선택 값의 글 근거."""
|
||||
bits = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")]
|
||||
bits += [x.get("규격") for x in doc.get("금액") or []]
|
||||
for v in doc.get("변수") or []:
|
||||
bits += [str(x) for x in ((v.get("연결") or {}).get("입력") or {}).values()]
|
||||
return " ".join(str(b or "") for b in bits).replace(" ", "")
|
||||
|
||||
|
||||
def domains(
|
||||
doc: dict, imported: dict, regions: set[str] = frozenset()
|
||||
) -> tuple[dict[str, list], dict[str, set]]:
|
||||
"""선택 · 입력 변수 → (돌릴 값, 글 근거) — 기본 먼저 · 가져온 글에 든 선택 값(근거) 먼저 ·
|
||||
글 입력 지역 = 가져온 글에 든 지역 이름."""
|
||||
words, nums = _words(imported), numbers_of(imported)
|
||||
out: dict[str, list] = {}
|
||||
ev: dict[str, set] = {}
|
||||
for v in doc.get("변수") or []:
|
||||
link = v.get("연결") or {}
|
||||
kind, base = link.get("갈래"), link.get("값")
|
||||
if kind == "선택":
|
||||
opts = list(link.get("목록") or [])
|
||||
seen = [o for o in opts if str(o).replace(" ", "") in words and len(str(o)) > 1]
|
||||
seen.sort(key=lambda o: -len(str(o)))
|
||||
if seen:
|
||||
ev[md.var_id(v)] = set(seen)
|
||||
rest = [o for o in opts if o not in seen]
|
||||
first = [base] if base in rest else []
|
||||
out[md.var_id(v)] = seen + first + [o for o in rest if o != base]
|
||||
elif kind == "입력":
|
||||
try:
|
||||
d = Decimal(str(base).replace(",", "")).normalize()
|
||||
except (InvalidOperation, ValueError):
|
||||
if str(v.get("열쇠") or "").endswith("지역"):
|
||||
seen = sorted((r for r in regions if len(r) > 1 and r in words), key=len)
|
||||
out[md.var_id(v)] = list(dict.fromkeys([base, *seen[::-1]]))
|
||||
continue # 그 밖 글 입력 = 기본 그대로
|
||||
low, high = (mc.number(x) for x in (link.get("범위") or [None, None]))
|
||||
vals = [d, Decimal(0)] + nums
|
||||
vals = [x for x in vals if (low is None or x >= low) and (high is None or x <= high)]
|
||||
out[md.var_id(v)] = list(dict.fromkeys(vals))[:CAP_INPUT]
|
||||
return out, ev
|
||||
|
||||
|
||||
def _given(doc: dict, pick: dict) -> dict:
|
||||
ids = {md.var_id(v): v.get("열쇠") or md.var_id(v) for v in doc.get("변수") or []}
|
||||
return {ids[k]: v for k, v in pick.items()}
|
||||
|
||||
|
||||
def _eval(doc: dict, links: Any, pick: dict, text: Any, memo: dict) -> Any:
|
||||
run = mc._Run(doc, links, _given(doc, pick), (str(doc.get("키")),), links.calls)
|
||||
got = run.expr(text, "줄")
|
||||
if isinstance(got, Decimal) and got == 0:
|
||||
return 0
|
||||
return _key(got) if isinstance(got, (Decimal, dict)) else None
|
||||
|
||||
|
||||
def narrow(
|
||||
doc: dict, links: Any, dom: dict, want: dict[str, set], ev: dict[str, set] | None = None
|
||||
) -> dict[str, list]:
|
||||
"""줄마다 의존 변수만 돌려 가져온 값(수량 · 단가) · 0 에 닿는 값만 남김(두 바퀴) ·
|
||||
글 근거 값(`ev`)은 안 지움(값이 달라도 그 조합을 셈 — 다른 항으로 드러냄)."""
|
||||
ev = ev or {}
|
||||
close = _closure(doc)
|
||||
memo: dict = {}
|
||||
base = {k: vs[0] for k, vs in dom.items()}
|
||||
jobs = []
|
||||
for line in doc.get("금액") or []:
|
||||
for slot, field in (("수량", "수량"), ("단가", "단가")):
|
||||
uses = _uses(line.get(field), close)
|
||||
if uses is None or uses & (set(md.MONEY_NAMES) | _money_syms(doc)):
|
||||
continue
|
||||
free = sorted(u for u in uses if u in dom)
|
||||
if free:
|
||||
jobs.append((line.get(field), free, want[slot]))
|
||||
for _ in range(2):
|
||||
for text, free, targets in jobs:
|
||||
space = [dom[f] for f in free]
|
||||
if _size(space) > CAP_LINE:
|
||||
continue
|
||||
ok: dict[str, set] = {f: set() for f in free}
|
||||
for combo in itertools.product(*space):
|
||||
pick = {**base, **dict(zip(free, combo))}
|
||||
got = _eval(doc, links, pick, text, memo)
|
||||
if got == 0 or got in targets:
|
||||
for f, c in zip(free, combo):
|
||||
ok[f].add(c)
|
||||
if all(ok.values()):
|
||||
for f in free:
|
||||
ok[f] |= ev.get(f, set())
|
||||
dom[f] = [x for x in dom[f] if x in ok[f]]
|
||||
base[f] = dom[f][0]
|
||||
return dom
|
||||
|
||||
|
||||
def _money_syms(doc: dict) -> set[str]:
|
||||
return {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")}
|
||||
|
||||
|
||||
def _size(space: list[list]) -> int:
|
||||
n = 1
|
||||
for s in space:
|
||||
n *= max(len(s), 1)
|
||||
return n
|
||||
|
||||
|
||||
def combos(doc: dict, dom: dict) -> list[dict]:
|
||||
"""좁힌 값 조합(기본 쪽 먼저 · 상한 `CAP_ALL`) — 한 값뿐인 변수는 그 값."""
|
||||
keys = [k for k, v in dom.items() if v]
|
||||
out = []
|
||||
for combo in itertools.product(*(dom[k] for k in keys)):
|
||||
out.append(dict(zip(keys, combo)))
|
||||
if len(out) >= CAP_ALL:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# ── ③ 줄 대조 ─────────────────────────────────────────────────────────
|
||||
def _ratio(a: Any, b: Any) -> str | None:
|
||||
if not isinstance(a, Decimal) or not isinstance(b, Decimal) or not a or not b:
|
||||
return None
|
||||
for n in NICE:
|
||||
if _key(a * n) == b:
|
||||
return f"× {n}"
|
||||
if _key(b * n) == a:
|
||||
return f"÷ {n}"
|
||||
return None
|
||||
|
||||
|
||||
def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]:
|
||||
"""가져온 줄(mine) ↔ M01 줄(ours) → {같음, 짝, 핵심, 다른 [{항, 글}]}."""
|
||||
left, right = list(mine), list(ours)
|
||||
same = paired = 0
|
||||
diffs: list[dict] = []
|
||||
|
||||
def take(test) -> list[tuple[dict, dict]]:
|
||||
hits = []
|
||||
for a in list(left):
|
||||
b = next((x for x in right if test(a, x)), None)
|
||||
if b is not None:
|
||||
left.remove(a)
|
||||
right.remove(b)
|
||||
hits.append((a, b))
|
||||
return hits
|
||||
|
||||
def core(a: dict, b: dict) -> bool:
|
||||
return a["갈래"] != "율" and b["갈래"] != "율"
|
||||
|
||||
def cls(a: dict, b: dict) -> bool:
|
||||
return a["갈래"] == b["갈래"] or {a["갈래"], b["갈래"]} <= {"기계", "산출"}
|
||||
|
||||
exact = take(
|
||||
lambda a, b: (
|
||||
a["수량"] == b["수량"] and a["단가"] == b["단가"] and a["비목"] == b["비목"]
|
||||
if core(a, b)
|
||||
else a["갈래"] == b["갈래"] == "율" and a["비목"] == b["비목"]
|
||||
)
|
||||
)
|
||||
same += sum(1 for a, _ in exact if a["갈래"] != "율")
|
||||
for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["수량"] == b["수량"]):
|
||||
paired += 1
|
||||
if a["갈래"] == "기계" and b["갈래"] == "기계" and a["코드"] != b["코드"]:
|
||||
diffs.append({"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']}"})
|
||||
elif a["단가"] != b["단가"]:
|
||||
diffs.append({"항": "단가 차이", "글": f"{a['이름']} 단가 {a['단가']} ↔ {b['단가']}"})
|
||||
else:
|
||||
diffs.append({"항": "비목", "글": f"{a['이름']} 비목 다름"})
|
||||
for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["단가"] == b["단가"]):
|
||||
paired += 1
|
||||
how = _ratio(a["수량"], b["수량"])
|
||||
what = "단위 환산" if how else "수량"
|
||||
diffs.append({"항": what, "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how or ''}"})
|
||||
for a, b in take(lambda a, b: core(a, b) and a["갈래"] == b["갈래"] == "기계"):
|
||||
paired += 1
|
||||
diffs.append(
|
||||
{"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']} · 수량 다름"}
|
||||
)
|
||||
for a, b in take(lambda a, b: a["갈래"] == b["갈래"] == "율" and a["금액"] == b["금액"]):
|
||||
diffs.append({"항": "비목", "글": f"율 줄 {a['이름']} 비목 다름"})
|
||||
for a, b in take(lambda a, b: a["갈래"] == b["갈래"] == "율"):
|
||||
diffs.append({"항": "율 값", "글": f"{a['이름']} {a['금액']} ↔ {b['이름']} {b['금액']}"})
|
||||
for side, rows in (("가져온 쪽만", left), ("우리 쪽만", right)):
|
||||
for x in rows:
|
||||
if x["갈래"] == "율":
|
||||
what = "공구손료 유무" if "공구" in x["이름"] else "율 줄"
|
||||
else:
|
||||
what = "줄 없음"
|
||||
diffs.append({"항": what, "글": f"{side} — {x['이름']}"})
|
||||
n_core = sum(1 for a in mine if a["갈래"] != "율")
|
||||
return {"같음": same, "짝": same + paired, "핵심": n_core, "다른": diffs}
|
||||
|
||||
|
||||
def _totals(got: dict) -> dict:
|
||||
return {k: _key(mc.number(got.get(k))) for k in md.BUNDLE}
|
||||
|
||||
|
||||
EXPLAINED = (
|
||||
"이름 다름",
|
||||
"단가 차이",
|
||||
"단위 환산",
|
||||
"기계 규격",
|
||||
"비목",
|
||||
"율 값",
|
||||
"율 줄",
|
||||
"공구손료 유무",
|
||||
"끝수",
|
||||
)
|
||||
|
||||
|
||||
def verdict(
|
||||
mine: list[dict], ours: list[dict], got_a: dict, got_b: dict, off: list[dict] = ()
|
||||
) -> dict:
|
||||
"""확정 = 다른 항 0(옵션 글 근거까지 맞음) · 근접 = 핵심 줄 반 넘게 짝 + (같은 줄 있음 또는
|
||||
다른 항이 모두 까닭 있는 갈래 — 단가 · 단위 환산 · 기계 규격 · 비목 · 율) · 그 밖 없음."""
|
||||
res = compare(mine, ours)
|
||||
res["다른"] = [*off, *res["다른"]]
|
||||
ta, tb = _totals(got_a), _totals(got_b)
|
||||
if not res["다른"] and ta != tb:
|
||||
res["다른"].append({"항": "끝수", "글": f"줄은 같고 합이 다름 {ta} ↔ {tb}"})
|
||||
if not res["다른"] and res["같음"]:
|
||||
res["판정"] = SURE
|
||||
elif res["짝"] * 2 >= res["핵심"] + 1 and (
|
||||
res["같음"] or all(d["항"] in EXPLAINED for d in res["다른"])
|
||||
):
|
||||
res["판정"] = NEAR
|
||||
else:
|
||||
res["판정"] = NONE
|
||||
return res
|
||||
|
||||
|
||||
# ── 한 문서 ───────────────────────────────────────────────────────────
|
||||
def _calc(doc: dict, links: Any, given: dict) -> dict | None:
|
||||
try:
|
||||
got = mc.calc(doc, links, given, (), getattr(links, "calls", None))
|
||||
except Exception: # noqa: BLE001 — 못 셈 = 그 조합 버림
|
||||
return None
|
||||
return got if got.get("셈됨") else None
|
||||
|
||||
|
||||
def try_doc(
|
||||
base: dict, links: Any, imported: dict, got_imp: dict, mine: list[dict], regions: set[str]
|
||||
) -> dict:
|
||||
"""M01 문서 하나 — 옵션 풀이 · 가장 나은 조합 판정(이름 · 절 근거 없으면 「이름 다름」 = 확정 아님)."""
|
||||
want = {
|
||||
"수량": {x["수량"] for x in mine if x["수량"] is not None},
|
||||
"단가": {x["단가"] for x in mine if x["단가"] is not None},
|
||||
}
|
||||
dom, ev = domains(base, imported, regions)
|
||||
dom = narrow(base, links, dom, want, ev)
|
||||
named = [] if same_name(imported, base) else [_unnamed(imported, base)]
|
||||
best: dict | None = None
|
||||
for pick in combos(base, dom) or [{}]:
|
||||
got = _calc(base, links, _given(base, pick))
|
||||
if got is None:
|
||||
continue
|
||||
off = [_off(base, k, pick[k], ev[k]) for k in ev if k in pick and pick[k] not in ev[k]]
|
||||
off += named
|
||||
res = verdict(mine, facts(base, got), got_imp, got, off)
|
||||
rank = (res["판정"] == SURE, -len(off), res["같음"], res["짝"], -len(res["다른"]))
|
||||
if best is None or rank > best["순위"]:
|
||||
best = {**res, "순위": rank, "옵션": pick, "금액": _totals(got)}
|
||||
if res["판정"] == SURE:
|
||||
break
|
||||
return best or {"판정": NONE, "같음": 0, "짝": 0, "다른": [], "순위": (False, -9, 0, 0, 0)}
|
||||
|
||||
|
||||
def _off(doc: dict, vid: str, value: Any, seen: set) -> dict:
|
||||
"""옵션 값이 가져온 글 근거와 다름 — 다른 항 「옵션 글」."""
|
||||
var = next((v for v in doc.get("변수") or [] if md.var_id(v) == vid), {})
|
||||
name = var.get("이름") or vid
|
||||
return {"항": "옵션 글", "글": f"{name} 글 {'·'.join(map(str, seen))} ↔ {value}"}
|
||||
|
||||
|
||||
def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str, Any]:
|
||||
"""가져온 식 문서 → 연결후보 `{판정, 키, 이름, 옵션, 다른, 금액, 후보수}`."""
|
||||
links = links if isinstance(links, Memo) else Memo(links)
|
||||
got_imp = _calc(imported, links_imp, {})
|
||||
if got_imp is None:
|
||||
return {"판정": NONE, "까닭": "가져온 식을 우리 단가로 못 셈", "후보수": 0}
|
||||
mine = facts(imported, got_imp)
|
||||
keys = index.candidates(imported)
|
||||
best, best_key = None, None
|
||||
for key in keys:
|
||||
res = try_doc(index.docs[key], links, imported, got_imp, mine, index.regions)
|
||||
if best is None or res["순위"] > best["순위"]:
|
||||
best, best_key = res, key
|
||||
if res["판정"] == SURE:
|
||||
break
|
||||
if best is None:
|
||||
return {"판정": NONE, "후보수": 0}
|
||||
base = index.docs[best_key]
|
||||
if best["판정"] == NONE: # 가까운 후보(보이기만 — 단추 없음)
|
||||
near = {"키": best_key, "이름": base.get("이름"), "다른": best["다른"][:12]}
|
||||
return {"판정": NONE, "후보수": len(keys), "가까움": near}
|
||||
return {
|
||||
"판정": best["판정"],
|
||||
"키": best_key,
|
||||
"이름": base.get("이름"),
|
||||
"옵션": _shown(base, best["옵션"]),
|
||||
"다른": best["다른"],
|
||||
"금액": {"가져온": _plain(_totals(got_imp)), "우리": _plain(best["금액"])},
|
||||
"후보수": len(keys),
|
||||
}
|
||||
|
||||
|
||||
def _shown(doc: dict, pick: dict) -> dict[str, Any]:
|
||||
"""고른 옵션(기본과 다른 것) — {열쇠: 값}(수 = 수 · 선택 목록의 수 값과 같은 꼴 · SK 입력값)."""
|
||||
out = {}
|
||||
for v in doc.get("변수") or []:
|
||||
vid = md.var_id(v)
|
||||
if vid in pick and mc.plain_text(mc.number(pick[vid])) != mc.plain_text(
|
||||
mc.number((v.get("연결") or {}).get("값"))
|
||||
):
|
||||
out[str(v.get("열쇠") or vid)] = ml._norm(mc.number(pick[vid]))
|
||||
return out
|
||||
|
||||
|
||||
_STOP = {"설치", "해체", "인력", "기계", "작업", "운반", "타설", "제작"}
|
||||
|
||||
|
||||
def _grams(texts: list[Any]) -> set[str]:
|
||||
"""이름 낱말 — 한글 두 글자 묶음 · 영문 · 수 낱말(일반 낱말 뺌)."""
|
||||
out: set[str] = set()
|
||||
for t in texts:
|
||||
t = re.sub(r"\s|및", "", str(t or ""))
|
||||
for run in re.findall(r"[가-힣]+", t):
|
||||
out |= {run[i : i + 2] for i in range(len(run) - 1)}
|
||||
out |= {w.lower() for w in re.findall(r"[A-Za-z]{2,}", t)}
|
||||
return out - _STOP
|
||||
|
||||
|
||||
def same_name(imported: dict, base: dict) -> bool:
|
||||
"""뜻 근거 — 품셈 절 번호가 같거나 이름 낱말이 겹침(값만 같은 남의 기초를 확정으로 안 묶음)."""
|
||||
secs = sections([x.get("글") for x in (imported.get("가져온") or {}).get("추정") or []])
|
||||
if secs & sections([base.get("출처")]):
|
||||
return True
|
||||
return bool(_grams([imported.get("이름")]) & _grams([base.get("이름")]))
|
||||
|
||||
|
||||
def _unnamed(imported: dict, base: dict) -> dict:
|
||||
return {"항": "이름 다름", "글": f"{imported.get('이름')} ↔ {base.get('이름')}"}
|
||||
|
||||
|
||||
def _plain(d: dict) -> dict:
|
||||
return {k: (None if v is None else float(v)) for k, v in d.items()}
|
||||
@@ -1,104 +0,0 @@
|
||||
"""가져온 식 SK ↔ 우리 M01 기초 — 연결 찾기 기록 · 「확정」 때만 우리 기초 사본으로 바꾸기 · 되돌리기
|
||||
(PLAN 810-4 · 요구 707 · 708 · 709 · 703) — 서버 단독.
|
||||
|
||||
찾기(`find`) = 가져온 식 문서(우리 단가로 셈) → `M02_Doc_Match.match` → SK `가져온.원본.연결후보` 에 적음.
|
||||
바꾸기(`swap`) = 서버가 다시 찾아 「확정」 일 때만 — 같은 SK 키에 M01 사본 SK(바탕 = 그 기초 ·
|
||||
입력값 = 찾은 옵션 · 802-4a 꼴 · 수식 사본은 저장 때 서버가 찍음)를 씀 · 원래 가져온 식 SK 몸은
|
||||
`가져온보관` 칸에 그대로 둠 · 일위대가 줄 참조 = 키 그대로(SK 내용만 바뀜 → 금액 다시 셈).
|
||||
되돌리기(`back`) = `가져온보관` 몸을 그 키에 다시 씀(가져온 식 금액 그대로).
|
||||
우리 M01 기초는 어느 갈래에서도 안 바뀜(708) · 근접 · 없음 = 바꾸기 409(703).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_time import utc_now_text
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from M02_MasterTemplete import M02_Doc_Import as dim
|
||||
from M02_MasterTemplete import M02_Doc_Match as dm
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
||||
|
||||
KEEP = "가져온보관" # 바꾼 SK 의 원래 가져온 식 몸(되돌리기)
|
||||
_INDEX: dict[str, Any] = {"문서": None, "색인": None}
|
||||
|
||||
|
||||
def _bad(why: str, status: int = 400) -> store.StoreError:
|
||||
return store.StoreError(status, why)
|
||||
|
||||
|
||||
def index() -> dm.Index:
|
||||
"""M01 수식 문서 색인 — 폴더가 바뀌면(M01 관리자 고침) 다시."""
|
||||
docs = dc.folder()
|
||||
if _INDEX["문서"] is not docs:
|
||||
_INDEX["문서"], _INDEX["색인"] = docs, dm.Index(docs)
|
||||
return _INDEX["색인"]
|
||||
|
||||
|
||||
def _read(key: str) -> dict[str, Any]:
|
||||
if not sd.KEY.match(str(key or "")):
|
||||
raise _bad("키(SK-######)")
|
||||
return store.read(sd.KIND, key)
|
||||
|
||||
|
||||
def judge(sk: dict) -> dict[str, Any]:
|
||||
"""가져온 식 SK → 연결후보(저장 없음)."""
|
||||
whole = imp._whole()
|
||||
links_imp = dim.Links(whole, "우리")
|
||||
doc = dim.of(sk)
|
||||
links_imp.learn(doc)
|
||||
got = dm.match(doc, links_imp, index(), dm.ml.Links(whole, dc.folder()))
|
||||
return {**got, "때": utc_now_text()}
|
||||
|
||||
|
||||
def find(key: str, version: str) -> dict[str, Any]:
|
||||
"""연결 찾기 → SK `가져온.원본.연결후보` 에 적음 → {연결후보, 판}."""
|
||||
got = _read(key)
|
||||
sk = got["문서"]
|
||||
if not dim.is_imported(sk):
|
||||
raise _bad("가져온 식 단가산출만 연결을 찾음")
|
||||
found = judge(sk)
|
||||
meta = sk[imp.KEY]
|
||||
new = {**sk, imp.KEY: {**meta, "원본": {**(meta.get("원본") or {}), dm.KEY: found}}}
|
||||
info = store.write(sd.KIND, key, version or got["판"], new)
|
||||
return {dm.KEY: found, "판": info.get("판")}
|
||||
|
||||
|
||||
def copy_body(sk: dict, found: dict) -> dict[str, Any]:
|
||||
"""M01 사본 SK 몸(이름 · 단위 · 구분 그대로 · 바탕 = 찾은 기초 · 입력값 = 찾은 옵션)."""
|
||||
keep = {k: sk.get(k) for k in ("키", "이름", "단위", "구분", "상세구분") if sk.get(k)}
|
||||
old = {k: v for k, v in sk.items() if k not in ("키", "종류", KEEP)}
|
||||
body = {
|
||||
"덮개": {},
|
||||
"뺀줄": [],
|
||||
"더한줄": [],
|
||||
"고름": {},
|
||||
"자재표시": {},
|
||||
} # M01 그대로(고침 없음)
|
||||
base = {"바탕": {"로직": found["키"]}, "입력값": dict(found.get("옵션") or {})}
|
||||
return {**keep, **body, **base, KEEP: old}
|
||||
|
||||
|
||||
def swap(key: str, version: str) -> dict[str, Any]:
|
||||
"""「확정」 일 때만 우리 기초 사본으로 — 서버가 다시 찾음(화면 판정을 믿지 않음)."""
|
||||
got = _read(key)
|
||||
sk = got["문서"]
|
||||
if not dim.is_imported(sk):
|
||||
raise _bad("가져온 식 단가산출만 바꿈")
|
||||
found = judge(sk)
|
||||
if found.get("판정") != dm.SURE:
|
||||
raise _bad(f"우리 기초와 「확정」 일치가 아님({found.get('판정')}) — 바꾸지 않음", 409)
|
||||
info = store.write(sd.KIND, key, version or got["판"], copy_body(sk, found))
|
||||
return {dm.KEY: found, "판": info.get("판")}
|
||||
|
||||
|
||||
def back(key: str, version: str) -> dict[str, Any]:
|
||||
"""우리 기초 사본 → 원래 가져온 식(보관 몸 그대로)."""
|
||||
got = _read(key)
|
||||
old = got["문서"].get(KEEP)
|
||||
if not isinstance(old, dict) or not old.get(imp.KEY):
|
||||
raise _bad("되돌릴 가져온 식 없음")
|
||||
info = store.write(sd.KIND, key, version or got["판"], {**old, "키": key})
|
||||
return {"판": info.get("판")}
|
||||
@@ -79,6 +79,7 @@ class Block:
|
||||
self.got = {"확정": 0, "표지": 0, "추정": 0}
|
||||
self.memo: list[int] = [] # 글 줄 행(812-3 차림 · 분수 그림 위아래 줄 뺌)
|
||||
self.frac: set[int] = set()
|
||||
self.noted: set[tuple] = set() # 기계 표지 단 자원(812-9 · 블록에 한 번)
|
||||
|
||||
# ── 작은 도구 ──────────────────────────────────────────────────
|
||||
def mark(self, ln: dict, why: str) -> None:
|
||||
@@ -273,6 +274,10 @@ class Block:
|
||||
out["옮김"] = part if part != nat else None
|
||||
return out
|
||||
|
||||
def quote(self, r: dict) -> tuple[str | None, str | None]:
|
||||
"""견적 자원(갈래 고정) → 우리 자재 이음 · 표지(`M02_San_Link.quote`) · 그 밖 = (None, None)."""
|
||||
return em._m01("고정", r) if self.keys and r.get("갈래") == "고정" else (None, None)
|
||||
|
||||
def register(self, ln: dict, call: str) -> None:
|
||||
if ln.get("결과"):
|
||||
self.done.append((ln["결과"][-1], call, ln))
|
||||
@@ -298,21 +303,26 @@ class Block:
|
||||
self.got["확정"] += bool(got)
|
||||
self.register(ln, f"줄.'{name}'")
|
||||
|
||||
def triple(self, rows: list[dict], total: dict | None) -> None:
|
||||
def triple(self, rows: list[dict], total: dict | None, back: int = 0) -> None:
|
||||
"""기계 · 산근 · 견적 세 줄 → 로직 값묶음 한 줄(수량 식 · 자리 같을 때) · 아니면 줄마다."""
|
||||
got = [self.money(ln) for ln in rows]
|
||||
parts = [ln["비목"] for ln in rows]
|
||||
same = all(got) and len({g for g in got}) == 1 and sorted(parts) == ["L", "M", "S"]
|
||||
same = all(got) and len({repr(g) for g in got}) == 1 and sorted(parts) == ["L", "M", "S"]
|
||||
if not same:
|
||||
for ln, g in zip(rows, got):
|
||||
self.machine_line(ln, g)
|
||||
if total:
|
||||
self.subtotal(total, [call for _, call, _ in self.done[-len(rows) :]])
|
||||
if total: # 소계 = 세 줄 + 바로 앞 같은 기계 낱 줄(`back`)
|
||||
self.subtotal(total, [call for _, call, _ in self.done[-(len(rows) + back) :]])
|
||||
return
|
||||
qty, place = got[0]
|
||||
prices = {ln["비목"]: (ln.get("연결단가") or [0])[0] for ln in rows}
|
||||
r = self.res(rows[0], None, None)
|
||||
r |= {"단가": prices}
|
||||
if quoted := self.quote(r)[1]: # 815-1 K5 — 세 줄 견적 = 값묶음 그대로 · 표지 한 번
|
||||
self.mark(rows[0], quoted.removeprefix(NOTE))
|
||||
parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
|
||||
r |= {"찾기": parts[0]} if parts else {}
|
||||
r |= {"조합첫": True} if len(parts) > 1 else {}
|
||||
head = next(
|
||||
(b for b in reversed(self.lines[: self.lines.index(rows[0])])
|
||||
if (b.get("자원") or {}).get("코드") == r.get("코드") and not b.get("비목")),
|
||||
@@ -321,7 +331,10 @@ class Block:
|
||||
title = head.get("품명") or r.get("원이름") or r.get("이름") or "줄"
|
||||
name = self.o.names.item(title)
|
||||
one = {"이름": name, "규격": r.get("규격") or "", "단위": r.get("단위") or "", "수량": qty}
|
||||
one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": self.logic_link(r), "자원": r}
|
||||
one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": None, "자원": r}
|
||||
em.first_spec(one, parts)
|
||||
if parts: # 814-1 조합(굴착기 + 브레이커 · 집게) = 우리 조합 식 한 줄
|
||||
one["이음"] = em.combo_link(one, parts) or em.hourly_text(parts[0]["키"])
|
||||
one |= {"자리": place} if place else {}
|
||||
value = total["결과"][-1] if total and total.get("결과") else None
|
||||
for ln in rows: # 노 · 재 · 경 글 = 펼침(맞댈 값은 한 줄 계 — 소계 글 · 없으면 첫 줄)
|
||||
@@ -329,37 +342,42 @@ class Block:
|
||||
at = total or rows[0]
|
||||
value = value if value is not None else sum(ln["결과"][-1] for ln in rows)
|
||||
self.o.add_line(one, True, at["행"], at.get("보임") or "", value)
|
||||
call = self.parts(one, parts, at, rows[0])
|
||||
if total:
|
||||
self.register(total, f"줄.'{name}'")
|
||||
self.register(total, call)
|
||||
self.got["확정"] += 3
|
||||
|
||||
def parts(self, one: dict, parts: list[dict], at: dict, ln: dict) -> str:
|
||||
"""조합 기계 다음 줄(값 0 · 제 이음) · 기계 표지(블록에 한 번) → 이 기계 부름 글(줄 합)."""
|
||||
calls = [f"줄.'{one['이름']}'"]
|
||||
for p in parts[1:]:
|
||||
extra = em.part_line(one, p, self.o.names)
|
||||
self.o.add_line(extra, True, at["행"], at.get("보임") or "", None)
|
||||
calls.append(f"줄.'{extra['이름']}'")
|
||||
seen = (one["자원"].get("이름"), one["자원"].get("규격"))
|
||||
if seen not in self.noted:
|
||||
self.noted.add(seen)
|
||||
for m in em.part_marks(one["자원"], parts):
|
||||
self.mark(ln, m.removeprefix(NOTE))
|
||||
return calls[0] if len(calls) == 1 else f"({' + '.join(calls)})"
|
||||
|
||||
def machine_line(self, ln: dict, got: tuple | None) -> None:
|
||||
qty, place = got if got else ("1", self.frozen(ln, "수량 식 못 읽음")[1])
|
||||
price = (ln.get("연결단가") or [ln["결과"][-1]])[0]
|
||||
r = self.res(ln, ln["비목"], price if got else ln["결과"][-1])
|
||||
r["갈래"] = "기계" if r["갈래"] == "기계" else r["갈래"]
|
||||
name = self.o.names.item(f"{ln.get('품명') or r.get('이름') or '줄'} {COMP[ln['비목']]}")
|
||||
link, _ = em._m01(r["갈래"], r) if self.keys else (None, None)
|
||||
parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
|
||||
r |= {"찾기": parts[0]} if parts else {}
|
||||
r |= {"조합첫": True} if len(parts) > 1 else {}
|
||||
link, many = (parts[0]["키"], None) if parts else self.quote(r)
|
||||
one = em.resource(name, r.get("단위"), qty, r, link)
|
||||
em.first_spec(one, parts)
|
||||
one |= {"자리": place} if place else {}
|
||||
self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1])
|
||||
if many:
|
||||
self.mark(ln, many.removeprefix(NOTE))
|
||||
self.got["확정"] += bool(got)
|
||||
self.register(ln, f"줄.'{name}'")
|
||||
|
||||
def logic_link(self, r: dict) -> str | None:
|
||||
"""기계 = 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 그 밖 = 없음(산근 = 형제 SK 참조)."""
|
||||
if not self.keys or r.get("갈래") != "기계":
|
||||
return None
|
||||
key, _ = em._m01("기계", r)
|
||||
if not key:
|
||||
return None
|
||||
from M02_MasterTemplete import M02_Doc_Import as dim
|
||||
|
||||
got = dim.hourly_link(key, "노무비")
|
||||
if not got:
|
||||
return None
|
||||
args = "".join(f", {k}={v}" for k, v in got["입력"].items())
|
||||
return f"로직({got['키']}{args})"
|
||||
self.register(ln, self.parts(one, parts, ln, ln))
|
||||
|
||||
def subtotal(
|
||||
self, ln: dict, kids: list[str], part: str | None = None, glyph: bool = True
|
||||
@@ -542,8 +560,22 @@ class Block:
|
||||
rows.append(lines[i + len(rows)])
|
||||
nxt = lines[i + len(rows)] if i + len(rows) < len(lines) else None
|
||||
total = nxt if nxt and nxt.get("연결") == link and nxt.get("비목") == "T" else None
|
||||
back, seen = (
|
||||
0,
|
||||
{r.get("비목") for r in rows},
|
||||
) # 바로 앞 같은 기계 낱 줄(얼린 줄 · 814-1)
|
||||
while i - back > 0 and len(seen) < 3:
|
||||
b = lines[i - back - 1]
|
||||
if (
|
||||
(b.get("자원") or {}).get("코드") != code
|
||||
or b.get("비목") in seen
|
||||
or not b.get("결과")
|
||||
):
|
||||
break
|
||||
seen.add(b.get("비목"))
|
||||
back += 1
|
||||
if all(r.get("결과") for r in rows):
|
||||
self.triple(rows, total)
|
||||
self.triple(rows, total, back)
|
||||
else:
|
||||
for r in rows:
|
||||
self.mark(r, "결과 없는 세 줄")
|
||||
|
||||
@@ -85,6 +85,9 @@ def apply(doc: dict, meta: dict) -> None:
|
||||
if j is not None:
|
||||
at[j] = min(at.get(j, t["행"]), t["행"])
|
||||
rows = _fill([at.get(j) for j in range(len(money))])
|
||||
for j in range(1, len(rows)): # 조합 다음 줄을 세 줄 뒤로 옮긴 것(814-1 `Import_Shape.regroup`)
|
||||
if money[j].get("_뒤"):
|
||||
rows[j] = max(rows[j], rows[j - 1])
|
||||
order = sorted(range(len(money)), key=lambda j: (rows[j], j))
|
||||
if order != list(range(len(money))) and _safe(doc, order):
|
||||
doc["금액"] = money = [money[j] for j in order]
|
||||
@@ -166,6 +169,12 @@ def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dic
|
||||
items: list[dict] = []
|
||||
cur: dict | None = None
|
||||
cut = False # STmate 계 줄 뒤 — 다음 첫 글 줄 = 제목
|
||||
money = doc.get("금액") or []
|
||||
mach = {
|
||||
str(x.get("기호")): x
|
||||
for x in (doc.get("가져온") or {}).get("기계") or []
|
||||
if isinstance(x, dict) and (x.get("조합") or x.get("조합첫"))
|
||||
} # 조합 기계(812-9 · 요구 738-3) = 기계마다 항목 하나 · 제목 = 그 기계
|
||||
|
||||
def new(title: str = "", sub: bool = False) -> dict:
|
||||
it = {"제목": title, "가나다": sub, "줄": [], "합계": False}
|
||||
@@ -186,6 +195,12 @@ def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dic
|
||||
cur = new(title, sub)
|
||||
cut = False
|
||||
else:
|
||||
one = mach.get(price_sym(money[val]) or "") if kind == "금액" else None
|
||||
if one and one.get("조합") and not (cur and cur.get("_조합") == _mach_title(one)):
|
||||
cur = new(_mach_title(one), bool(cur and cur["가나다"]))
|
||||
cur["_조합"] = _mach_title(one) # 같은 기계 다음 줄(비목 셋)은 한 항목
|
||||
elif one and cur is not None and "+" in cur["제목"]:
|
||||
cur["제목"] = _mach_title(one)
|
||||
cur = cur if cur is not None else new()
|
||||
if kind == "공식" and cur["줄"] and cur["줄"][-1][0] == "공식":
|
||||
cur["줄"][-1][1].extend(x for x in val if x not in cur["줄"][-1][1])
|
||||
@@ -200,6 +215,16 @@ def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dic
|
||||
return keep
|
||||
|
||||
|
||||
def _mach_title(one: dict) -> str:
|
||||
"""조합 기계 항목 제목 — 이은 우리 기계 이름 · 규격 › 가져온 이름 · 규격."""
|
||||
first = (one.get("후보") or [{}])[0] if one.get("키") else {}
|
||||
said = first or {
|
||||
"이름": (one.get("원래") or {}).get("이름"),
|
||||
"규격": (one.get("원래") or {}).get("규격"),
|
||||
}
|
||||
return " ".join(str(said.get(k) or "") for k in ("이름", "규격")).strip()
|
||||
|
||||
|
||||
def _save_item(it: dict, money: list[dict]) -> dict:
|
||||
out: dict[str, Any] = {}
|
||||
if it["제목"]:
|
||||
@@ -269,6 +294,12 @@ def price_sym(line: dict) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def machine_syms(doc: dict) -> set[str]:
|
||||
"""가져온 기계 줄 단가 기호(812-9 `가져온.기계`) — 못 이어 숫자 연결이어도 그 줄 기계(이름 단추 · 고르기)."""
|
||||
got = (doc.get("가져온") or {}).get("기계") if isinstance(doc.get("가져온"), dict) else None
|
||||
return {str(x.get("기호")) for x in got or [] if isinstance(x, dict)}
|
||||
|
||||
|
||||
def owned_of(doc: dict) -> dict[int, str]:
|
||||
count: dict[str, int] = {}
|
||||
for ns in (
|
||||
@@ -280,11 +311,17 @@ def owned_of(doc: dict) -> dict[int, str]:
|
||||
count[n] = count.get(n, 0) + 1
|
||||
vars_ = {md.var_id(v): v for v in doc.get("변수") or []}
|
||||
lines = {str(x.get("기호") or "") for x in doc.get("식") or []}
|
||||
mach = machine_syms(doc)
|
||||
out = {}
|
||||
for i, line in enumerate(doc.get("금액") or []):
|
||||
s = price_sym(line)
|
||||
kind = ((vars_.get(s) or {}).get("연결") or {}).get("갈래") if s else None
|
||||
if s and kind in ("요소", "단가산출") and count.get(s) == 1 and s not in lines:
|
||||
if (
|
||||
s
|
||||
and (kind in ("요소", "단가산출") or s in mach)
|
||||
and count.get(s) == 1
|
||||
and s not in lines
|
||||
):
|
||||
out[i] = s
|
||||
return out
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
POST /api/m02/layers/{층}/sangeun/{키}/doc/preview 미리 셈(층 쓰기) {문서?, 끊음?, 할일?}
|
||||
→ {…, 전, 후, 바뀐자원?, 버림?}
|
||||
POST /api/m02/layers/{층}/sangeun/{키}/doc/save 저장(층 쓰기) {문서?, 끊음?, 할일?, 판} → 읽기 꼴
|
||||
POST /api/m02/layers/{층}/sangeun/{키}/match {할일 찾기|바꾸기|되돌리기, 판?}(층 쓰기 · 810-4)
|
||||
→ {연결후보?, 판}(바꾸기 = 확정만 · 아니면 409)
|
||||
옛 별칭 장부 쓰기(812-9) · 연결 찾기 길(810-4)은 걷음(815-2 · 요구 762-1 · 765).
|
||||
⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서).
|
||||
"""
|
||||
|
||||
@@ -50,31 +49,6 @@ def preview_doc(
|
||||
return _call(folder, path.preview, key, body.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
class MatchBody(BaseModel):
|
||||
할일: str = "찾기" # 찾기 | 바꾸기 | 되돌리기(810-4)
|
||||
판: str | None = None
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/{key}/match")
|
||||
def match_doc(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
body: MatchBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""가져온 식 ↔ 우리 기초(810-4) — 찾기(연결후보 적음) · 바꾸기(확정만 · M01 사본 SK) · 되돌리기."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_MatchSk as msk
|
||||
|
||||
jobs = {"찾기": msk.find, "바꾸기": msk.swap, "되돌리기": msk.back}
|
||||
if body.할일 not in jobs:
|
||||
raise HTTPException(status_code=400, detail="찾기 · 바꾸기 · 되돌리기")
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, jobs[body.할일], key, body.판 or "")
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/{key}/doc/save")
|
||||
def save_doc(
|
||||
layer: Layer,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""M02 불러오기 요소 잇기 색인(PLAN 815-1 · 요구 755 ~ 764) — M01 요소 줄 「별칭」 칸 · EST 참조 코드 ·
|
||||
재료 정확 일치. 서버 단독 · 읽기만(M01 판이 바뀌면 다시 읽음 · `id(whole)`).
|
||||
|
||||
- `keys(표, 이름, 규격)` = 별칭으로 이어지는 키들(풀이 = `master_alias` · 표 LB 인력 · EQ 기계 · MT 재료 ·
|
||||
MO 유가) — 부르는 곳: 단가산출 불러오기(`M02_San_Link.m01`) · 1차 줄 판정(`M02_Hopyo_Row.m01_picks`) ·
|
||||
기계 찾기(`M02_Machine_Find.alias`) · 일위대가 호표 줄(`M02_Hopyo_Units`)
|
||||
- `est_code(코드)` = EST 원파일 자재 자원 코드(M…) ↔ 우리 자재 줄 `출처` 칸 「자재품목 M…」 같은 줄 ·
|
||||
공사 직접 번호(M0000000…)는 빼고 이름 길
|
||||
- `exact(이름, 규격)` = 지역 꼬리 뗀 이름 · 규격이 `master_alias.norm` 으로 같은 자재 줄(가까운 규격 아님)
|
||||
- `one(줄, 키들)` = 같은 품목(지역 꼬리 뗀 이름 · 규격 같음)의 지역 줄 여럿 → 줄 지역 › 서울 › 같은 단위 ›
|
||||
키 차례 하나 · 다른 품목이 섞이면 None(후보 여럿)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
import master_alias as am # noqa: E402 — M01 store 가 scripts 경로를 넣음
|
||||
|
||||
MARKET = "MT"
|
||||
PRICES = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재 값 칸 다섯
|
||||
BASE_REGION = "서울"
|
||||
_CODE = re.compile(r"\bM\d{10}\b")
|
||||
LOCAL = "M0000000" # 공사 직접 번호(파일마다 다른 품목 · 거창 치즐 = 우리 표 Air Hammer) = 코드로 안 이음
|
||||
_REGION = re.compile(r"\s*,\s*([가-힣]{2,4})\s*$") # 자재 이름 지역 꼬리 「, 서울」
|
||||
|
||||
|
||||
def region_of(name: Any) -> tuple[str, str]:
|
||||
"""자재 이름 → (지역 꼬리 뗀 이름, 지역 | 빈 글)."""
|
||||
text = str(name or "")
|
||||
m = _REGION.search(text)
|
||||
return (text[: m.start()], m.group(1)) if m else (text, "")
|
||||
|
||||
|
||||
def _same(name: Any, spec: Any) -> tuple[str, str]:
|
||||
return am.norm(region_of(name)[0]), am.norm(spec)
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _book(_whole: int) -> dict[str, Any]:
|
||||
whole = m01.loaded()[1]
|
||||
tables = {t: whole.numbers.get(t, {}) for t in ("LB", "EQ", "MO")}
|
||||
tables[MARKET] = whole.index.get(MARKET, {})
|
||||
rows = {t: {str(r.get("키") or k): r for k, r in got.items()} for t, got in tables.items()}
|
||||
alias = {t: am.index({"키": k, "별칭": r.get("별칭")} for k, r in got.items()) for t, got in rows.items()} # fmt: skip
|
||||
code: dict[str, list[str]] = {}
|
||||
exact: dict[tuple[str, str], list[str]] = {}
|
||||
for k, r in rows[MARKET].items():
|
||||
for c in _CODE.findall(str(r.get("출처") or "")):
|
||||
code.setdefault(c, []).append(k)
|
||||
exact.setdefault(_same(r.get("이름"), r.get("규격")), []).append(k)
|
||||
return {"줄": rows, "별칭": alias, "코드": code, "같음": exact}
|
||||
|
||||
|
||||
def book() -> dict[str, Any]:
|
||||
return _book(id(m01.loaded()[1]))
|
||||
|
||||
|
||||
def row(table: str, key: Any) -> dict[str, Any]:
|
||||
return book()["줄"].get(table, {}).get(str(key)) or {}
|
||||
|
||||
|
||||
def keys(table: str, name: Any, spec: Any = "") -> list[str]:
|
||||
"""별칭 칸으로 이어지는 키들(없음 = [])."""
|
||||
b = book()
|
||||
got = b["줄"].get(table, {})
|
||||
return am.match(b["별칭"].get(table, {}), lambda k: (got.get(k) or {}).get("규격"), name, spec)
|
||||
|
||||
|
||||
def priced(key: Any) -> bool:
|
||||
"""우리 자재 줄에 값 칸 다섯 중 하나라도 있음."""
|
||||
return any(row(MARKET, key).get(c) is not None for c in PRICES)
|
||||
|
||||
|
||||
def est_code(code: Any) -> list[str]:
|
||||
"""EST 자재 코드(M + 수 열) → 우리 자재 키들(출처 칸 같은 코드)."""
|
||||
text = str(code or "").strip()
|
||||
if not _CODE.fullmatch(text) or text.startswith(LOCAL):
|
||||
return []
|
||||
return list(book()["코드"].get(text, []))
|
||||
|
||||
|
||||
def exact(name: Any, spec: Any) -> list[str]:
|
||||
"""지역 꼬리 뗀 이름 · 규격 정규화 같음 자재 키들."""
|
||||
return list(book()["같음"].get(_same(name, spec), []))
|
||||
|
||||
|
||||
def one(line: dict[str, Any], found: list[str]) -> str | None:
|
||||
"""같은 품목 지역 줄 여럿 → 하나(줄 지역 › 서울 › 같은 단위 › 키) · 다른 품목 섞임 = None."""
|
||||
rows = {k: row(MARKET, k) for k in found}
|
||||
if not rows or len({_same(r.get("이름"), r.get("규격")) for r in rows.values()}) != 1:
|
||||
return None
|
||||
mine, unit = region_of(line.get("이름"))[1], am.norm(line.get("단위"))
|
||||
|
||||
def rank(k: str) -> tuple:
|
||||
where = region_of(rows[k].get("이름"))[1]
|
||||
return (where != mine if mine else True, where != BASE_REGION, am.norm(rows[k].get("단위")) != unit, k) # fmt: skip
|
||||
|
||||
return min(rows, key=rank)
|
||||
|
||||
|
||||
def material(line: dict[str, Any], code: Any = None) -> tuple[str | None, str]:
|
||||
"""재료 줄 → (우리 자재 키, 길 「코드」 · 「별칭」 · 「정확 일치」) · 별칭 여럿 = (None, 「별칭 후보 여럿」) ·
|
||||
없음 = (None, "")."""
|
||||
if hit := est_code(code):
|
||||
return one(line, hit) or hit[0], "코드"
|
||||
hit = keys(MARKET, line.get("이름"), line.get("규격"))
|
||||
if hit:
|
||||
got = hit[0] if len(hit) == 1 else one(line, hit)
|
||||
return (got, "별칭") if got else (None, "별칭 후보 여럿")
|
||||
got = one(line, exact(line.get("이름"), line.get("규격")))
|
||||
return (got, "정확 일치") if got else (None, "")
|
||||
@@ -19,8 +19,8 @@
|
||||
받아 `본뜬.전국자재 [{이름, 규격, 단위, 수량, 세 칸}]` 에 적고 빠진 줄에 까닭(금액 없음 ·
|
||||
지역은 프로젝트에서) · 장부엔 안 남김.
|
||||
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 없으면 같은 내용 SC 재사용(`재사용` · `M02_Hopyo_Kids.save`) ·
|
||||
`하위` = 부르는 하위 호표 `[{번, 키, 새로}]`(부모 줄 = 하위 사본) · 확인한 줄은 별칭 장부에 남음
|
||||
(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌).
|
||||
`하위` = 부르는 하위 호표 `[{번, 키, 새로}]`(부모 줄 = 하위 사본) · 별칭 장부엔 안 남김(815-2 걷음 ·
|
||||
`M02_Hopyo_Ledger` 는 짝 검사 · 열쇠 도우미만).
|
||||
빠짐 0(705-3) — 확인 안 한 줄 · 노임 · % · 「우리에 없음」 줄 = 고정 줄(`M02_Hopyo_Fixed` · 엑셀 숫자
|
||||
그대로) · 확인 안 한 호표 줄 = 확인된 하위 사전조합 참조(단위 같음 · 아니면 고정) · 빠진 줄 = 「안 씀」 ·
|
||||
통째 대신 · 로직 자재 겹침 · 전국만. `본뜬.품셈` = 품셈으로 넣은 엑셀 줄 차례([품셈으로 바꾸기] 첫 체크) ·
|
||||
@@ -248,18 +248,14 @@ class Builder:
|
||||
nation, mat_lines = body.nation, body.mat_lines
|
||||
poomsem, kid_n = [], 0
|
||||
|
||||
book: dict[str, dict[str, Any]] = {}
|
||||
if whole:
|
||||
for one in whole:
|
||||
put({"수량": 1, "단위": hopyo.get("단위"), "이름": hopyo.get("이름")}, one, None)
|
||||
book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole)
|
||||
for at, line in enumerate(rows):
|
||||
got = checked.get(at)
|
||||
if whole:
|
||||
missing.append(_missing(line, WHOLE))
|
||||
continue
|
||||
if got and got.get("뺌"):
|
||||
book[ledger.key(line)] = self._row(line, got["뺌"], [])
|
||||
if got and got.get("뺌") == "안 씀":
|
||||
missing.append(_missing(line, f"뺌 — {got['뺌']}"))
|
||||
continue
|
||||
@@ -277,9 +273,6 @@ class Builder:
|
||||
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
|
||||
for one in got["대상"]:
|
||||
put(line, one, alias)
|
||||
pairs = [p for p in got["대상"] if "호표" not in p and "전국" not in p]
|
||||
if pairs:
|
||||
book[ledger.key(line)] = self._row(line, "짝", pairs)
|
||||
for code in self._inside(lines, mats): # 로직 자재와 같은 품목 = 세 칸만 로직 자재표시로
|
||||
missing.append(_missing(mat_lines[code], TWICE))
|
||||
name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x)
|
||||
@@ -306,14 +299,11 @@ class Builder:
|
||||
count["단가산출"] = sum(1 for x in lines if _is_sk(x))
|
||||
sub = [{"번": n, "키": self.done.get(mark(self.key, n))} for n in sorted(kids)]
|
||||
if not save:
|
||||
ledger.write(self.folder, book, self.who, file or None, self.doc_id)
|
||||
head = {"문서": doc, "빠진줄": missing, "남은줄": left, "하위": sub}
|
||||
return {**head, "줄수": {**count, "이음후보": linker.near}}
|
||||
ref, again, self.reused = hkids.save(doc, self.same)
|
||||
wrote = ledger.write(self.folder, book, self.who, file or None, self.doc_id)
|
||||
out = {"참조": ref, "다시씀": again, "재사용": self.reused, "빠진줄": missing, "하위": sub}
|
||||
out["남은줄"] = left
|
||||
out["장부"] = {"씀": wrote}
|
||||
return {**out, "줄수": {**count, "이음후보": linker.near}}
|
||||
|
||||
def _kid(self, line: dict[str, Any], kids: set[int]) -> dict[str, Any] | None:
|
||||
@@ -351,17 +341,6 @@ class Builder:
|
||||
mats.pop(code)
|
||||
return moved
|
||||
|
||||
def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면)."""
|
||||
origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")}
|
||||
clauses = (line.get("근거") or {}).get("출처") or []
|
||||
return {
|
||||
"원래": {k: str(v or "") for k, v in origin.items()},
|
||||
"갈래": kind,
|
||||
"짝": pairs,
|
||||
"엑셀조항": clauses if isinstance(clauses, list) else [],
|
||||
}
|
||||
|
||||
|
||||
def _order(units: dict[int, dict[str, Any]], start: int | None = None) -> list[int]:
|
||||
"""하위 호표 먼저(호표 줄 참조) · 고리는 끊음 · `start` = 그 호표와 하위만."""
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
고르면 지금 잇기 길 `{노임|기계|자재|유가: 키}`).
|
||||
자재 = `M02_Hopyo_Material.candidates` 점수(이름 글자 묶음 · 규격) · 단가 = M01 자재 값(엑셀 줄 단위)
|
||||
노임 = LB 이름 글자 묶음 닮음 · 단가 = LB 값
|
||||
기계 = EQ 갈래 겹침 · 규격 수(같음 1 · 겹침 0.5) · 이름 낱말 덤 · 괄호 낱말 어긋남 깎음 ·
|
||||
단가 없음(기계경비는 로직 셈) · `machine` = 하나로 가름(778-1)
|
||||
기계 = 기계 찾기(812-9 `M02_Machine_Find` — 기계표 전체 · 표기 정규화 · 괄호 낱말 · 규격) 후보 ·
|
||||
단가 없음(기계경비는 로직 셈) · `machine` = 하나로 정할 때만
|
||||
유가(785-3) = MO 이름에 글 이름이 듦(경유 ⊂ 자동차용경유) · 전국 먼저 · 단가 = MO 값
|
||||
차례 = 점수(0.1 단위로 접음) › 엑셀 단가에 가까운 것(엑셀 단가 = 블록 단위 줄 단가 · 모르면 점수만).
|
||||
서버 단독 · 읽기만.
|
||||
@@ -21,18 +21,11 @@ from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||||
from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, nfkc, specnums
|
||||
from M02_MasterTemplete import M02_Machine_Find as mfind
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import nfkc
|
||||
|
||||
TOP = 3
|
||||
POOL = 5
|
||||
# 괄호 낱말(778-1) — 글에 있으면 EQ 이름에도 있어야 함 · 부속 = 글에 없으면 본 장비 아님
|
||||
FORMS = (
|
||||
("타이어", ("타이어",)),
|
||||
("무한", ("무한궤도",)),
|
||||
("습지", ("습지",)),
|
||||
("트럭탑재", ("트럭탑재",)),
|
||||
)
|
||||
PARTS = ("자동덮개", "부착용", "콤팩터", "습지")
|
||||
|
||||
|
||||
def prices(block: dict[str, Any] | None) -> dict[str, float]:
|
||||
@@ -125,57 +118,24 @@ def oils(name: str) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _form(text: str, name: Any) -> tuple[bool, bool]:
|
||||
"""괄호 낱말(778-1) — (글 형식 낱말이 EQ 이름에 다 있음, EQ 이름에 글에 없는 부속 있음)."""
|
||||
t, n = nfkc(text), nfkc(name)
|
||||
ok = all(any(w in n for w in ws) for k, ws in FORMS if k in t)
|
||||
return ok, any(w in n and w not in t for w in PARTS)
|
||||
def _eq(one: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = m01.loaded()[1].numbers.get("EQ", {}).get(str(one.get("원문번호")), {})
|
||||
unit = (raw.get("단위") or {}).get("내용시간") if isinstance(raw.get("단위"), dict) else ""
|
||||
return {"키": one["키"], "갈래": "기계", "이름": one["이름"], "규격": one.get("규격") or "",
|
||||
"단위": unit or "시간", "단가": None} # fmt: skip
|
||||
|
||||
|
||||
def narrow(text: str, rows: list[tuple[str, Any]]) -> list[str]:
|
||||
"""EQ `[(키, 이름)]` → 괄호 낱말로 가른 키 — 형식 다름 뺌 · 부속은 남는 것 있을 때만 뺌."""
|
||||
keep = [(c, _form(text, n)[1]) for c, n in rows if _form(text, n)[0]]
|
||||
return [c for c, part in keep if not part] or [c for c, _ in keep]
|
||||
def machine(text: str, *_: Any) -> dict | None:
|
||||
"""기계 글(이름 규격) → EQ 한 줄 — 기계 찾기(812-9 `M02_Machine_Find`)가 하나로 정할 때만."""
|
||||
got = mfind.find(text)
|
||||
if got["판정"] not in (mfind.LINKED, mfind.ALIAS):
|
||||
return None
|
||||
return m01.loaded()[1].numbers.get("EQ", {}).get(str(got["후보"][0]["원문번호"]))
|
||||
|
||||
|
||||
def machine(text: str, kinds: set | None = None, nums: set | None = None) -> dict | None:
|
||||
"""기계 글 → EQ 한 줄 — 갈래 겹침 + 규격 수 같음 · 괄호 낱말로 하나일 때만."""
|
||||
kinds = set(kinds or ()) or mtypes(text)
|
||||
nums = set(nums or ()) or set(specnums(text))
|
||||
rows = {
|
||||
str(r.get("키")): r
|
||||
for r in m01.loaded()[1].numbers.get("EQ", {}).values()
|
||||
if nums
|
||||
and kinds & mtypes(f"{r.get('상세구분')} {r.get('이름')}")
|
||||
and nums == set(specnums(r.get("규격")))
|
||||
}
|
||||
got = narrow(text, [(k, r.get("이름")) for k, r in rows.items()])
|
||||
return rows[got[0]] if len(got) == 1 else None
|
||||
|
||||
|
||||
def machines(text: str, kinds: set | None = None, nums: set | None = None) -> list[dict]:
|
||||
"""기계 글(이름 규격) → EQ 후보 — 갈래 · 규격 수 없으면 글에서 읽음 · 괄호 낱말 어긋남 깎음."""
|
||||
whole = m01.loaded()[1]
|
||||
kinds = set(kinds or ()) or mtypes(text)
|
||||
nums = set(nums or ()) or set(specnums(text))
|
||||
mine = hm.words(text)
|
||||
rows = []
|
||||
for row in whole.numbers.get("EQ", {}).values():
|
||||
types = mtypes(f"{row.get('상세구분')} {row.get('이름')}")
|
||||
if not kinds & types:
|
||||
continue
|
||||
spec = set(specnums(row.get("규격")))
|
||||
score = len(kinds & types) / len(kinds | types)
|
||||
score += 1.0 if nums and nums == spec else 0.5 if nums & spec else 0.0
|
||||
score += 0.3 * len(mine & hm.words(row.get("이름")))
|
||||
ok, part = _form(text, row.get("이름"))
|
||||
score -= (0.0 if ok else 1.0) + (0.5 if part else 0.0)
|
||||
one = {"키": str(row.get("키")), "갈래": "기계", "이름": row.get("이름")}
|
||||
unit = (row.get("단위") or {}).get("내용시간") if isinstance(row.get("단위"), dict) else ""
|
||||
rows.append(
|
||||
(score, {**one, "규격": row.get("규격") or "", "단위": unit or "시간", "단가": None})
|
||||
)
|
||||
return _order(rows, None)
|
||||
def machines(text: str, *_: Any) -> list[dict]:
|
||||
"""기계 글 → EQ 후보(보여주기만 · 812-9) — 이음 · 후보 여럿 = 그것 · 못 찾음 = 같은 기종(가까운 규격 먼저)."""
|
||||
return [_eq(one) for one in mfind.find(text)["후보"][:TOP]]
|
||||
|
||||
|
||||
def lost(one: dict[str, Any], want: float | None = None) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -20,17 +20,17 @@ from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||||
from M02_MasterTemplete import M02_Hopyo_Shares as shares
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, specnums
|
||||
from M02_MasterTemplete import M02_Machine_Find as mfind
|
||||
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
_BASE = re.compile(r"(?<!주)(노무비|재료비|경비|인건비)\s*의")
|
||||
GAP = Decimal(1) # 밑수 · 단가 같음 판정 끝수(원)
|
||||
_RATE = re.compile(r"\s*\(\s*\d+(?:\.\d+)?\s*%\s*\)\s*$") # 「…[hr] (90%)」 꼬리
|
||||
|
||||
|
||||
_d, _plain = fixed._num, fixed._plain
|
||||
@@ -93,13 +93,9 @@ class Linker:
|
||||
pair = (str(row.get("키")), _d(row.get("값")))
|
||||
self.labor.setdefault(str(row.get("이름") or ""), []).append(pair)
|
||||
self.machines = [
|
||||
(str(row.get("키")), mtypes(f"{row.get('상세구분')} {row.get('이름')}"),
|
||||
set(specnums(row.get("규격"))), mm_plain(row.get("이름")), mm_plain(row.get("규격")))
|
||||
(str(row.get("키")), mm_plain(row.get("이름")), mm_plain(row.get("규격")))
|
||||
for row in self.whole.numbers.get("EQ", {}).values()
|
||||
] # fmt: skip
|
||||
self.eq_names = {
|
||||
str(r.get("키")): r.get("이름") for r in self.whole.numbers.get("EQ", {}).values()
|
||||
}
|
||||
]
|
||||
self.near = 0 # 못 이은 이음 후보 수
|
||||
|
||||
def link(self, line: dict[str, Any], price: dict[str, Decimal]) -> dict[str, str | None]:
|
||||
@@ -117,14 +113,17 @@ class Linker:
|
||||
return out
|
||||
|
||||
def _eqs(self, line: dict[str, Any]) -> list[str]:
|
||||
"""기계 줄 → EQ 후보 — 이름 · 규격 글 같음 › 갈래 + 규격 수 · 괄호 낱말."""
|
||||
"""기계 줄 → EQ 후보 — 이름 · 규격 글 같음 › 기계 찾기(812-9 `M02_Machine_Find`)."""
|
||||
name, spec = mm_plain(line.get("이름")), mm_plain(line.get("규격"))
|
||||
same = [c for c, _, _, n, s in self.machines if n == name and s == spec]
|
||||
same = [c for c, n, s in self.machines if n == name and s == spec]
|
||||
if len(same) != 1:
|
||||
text = f"{line.get('이름') or ''} {line.get('규격') or ''}"
|
||||
kinds, nums = mtypes(text), set(specnums(text))
|
||||
same = [c for c, t, s, _, _ in self.machines if kinds & t and nums and nums == s]
|
||||
same = elem.narrow(text, [(c, self.eq_names.get(c)) for c in same])
|
||||
text = str(line.get("이름") or "")
|
||||
# 엑셀 호표 글 「운 반 : 덤프트럭(4.5Ton)」 · 「굴착기(…)[hr] : 90%」 = 쌍점 조각마다
|
||||
parts = [text, *(text.split(":") if ":" in text else [])]
|
||||
tries = [mfind.find(_RATE.sub("", p).strip(), line.get("규격")) for p in parts]
|
||||
got = next((g for g in tries if g["판정"] in (mfind.LINKED, mfind.ALIAS)), None)
|
||||
got = got or next((g for g in tries if g["판정"] != mfind.NONE), tries[0])
|
||||
same = [] if got["판정"] == mfind.NONE else [c["키"] for c in got["후보"]]
|
||||
return same
|
||||
|
||||
def candidates(self, line: dict[str, Any]) -> list[str]:
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
(줄 `불러오기` = `doc_id`)만 · 회사 · 개인 층 짝 = 「내 제안」(`제안` True · 자동 아님 ·
|
||||
후보 맨 위 · M01 승인 대기).
|
||||
읽는 차례 = 자동 정확 열쇠(같은 층 → 위 층) → 제안 정확 열쇠 → 품명|단위 느슨 열쇠(`규격다름`).
|
||||
쓰는 때 = [확인하고 사전조합 만들기] · 장부 화면 [저장]만 · 쓰기 = 그 층만(권한은 길 몫) ·
|
||||
⚠ 815-2 — 장부 화면 · M01 승인 · 불러오기 확인 때 쓰기는 걷음(별칭 = M01 요소 「별칭」 칸) ·
|
||||
남은 것 = 짝 검사 · 열쇠 도우미 · 요소 잇기의 옛 장부 읽기(sub3 815-1 이 뗌) · 쓰기 = 그 층만 ·
|
||||
짝은 서버가 다시 검사(돈 로직 · 그 로직 입력 이름 · M01 자재품목 · 배 0 넘음 · 몫 (0, 1]).
|
||||
"""
|
||||
|
||||
@@ -137,39 +138,6 @@ def recall(
|
||||
return None
|
||||
|
||||
|
||||
def _states(rows_: list[dict[str, Any]]) -> None:
|
||||
"""내 제안 승인 상태(739-1) — 마스터 줄 = 승인 · 아래 층 줄 = 마스터에 같은 짝(서명) 승인 ·
|
||||
M01 반려 기억 반려 · 그 밖 대기(M01 별칭 장부 `sign` · 반려 기억 그대로)."""
|
||||
from M01_MasterData import M01_MasterData_Alias as alias # M01 이 장부를 부름 — 늦게 읽음
|
||||
|
||||
master = read(store.FOLDER)
|
||||
gone = alias.rejected()
|
||||
for row in rows_:
|
||||
if row["층"] == "system":
|
||||
row["상태"] = "승인"
|
||||
continue
|
||||
sig, mine = alias.sign(alias.ALIAS, row), master.get(row["열쇠"])
|
||||
if mine and alias.sign(alias.ALIAS, mine) == sig:
|
||||
row["상태"] = "승인"
|
||||
elif alias._reject_id(alias.ALIAS, row["열쇠"], sig) in gone:
|
||||
row["상태"] = "반려"
|
||||
else:
|
||||
row["상태"] = "대기"
|
||||
|
||||
|
||||
def rows(folder: str | Path, writable: bool) -> list[dict[str, Any]]:
|
||||
"""장부 화면 — 이 층 + 위 층 줄 `{열쇠, …줄, 층, 주인, 고침, 상태}`(고침 = 이 층 · 쓰기 권한 ·
|
||||
상태 = 승인 · 대기 · 반려)."""
|
||||
out = []
|
||||
for at, (layer, owner, book) in enumerate(books(folder)):
|
||||
for full, row in sorted(book.items()):
|
||||
out.append(
|
||||
{"열쇠": full, **row, "층": layer, "주인": owner, "고침": writable and not at}
|
||||
)
|
||||
_states(out)
|
||||
return out
|
||||
|
||||
|
||||
# ── 검사 · 쓰기 ───────────────────────────────────────────────────────
|
||||
def _bad(text: str) -> store.StoreError:
|
||||
return store.StoreError(400, text)
|
||||
@@ -350,9 +318,9 @@ def write(
|
||||
|
||||
|
||||
def put(folder: str | Path, entries: Any, who: Any) -> dict[str, Any]:
|
||||
"""장부 화면 [저장] — `{열쇠: 줄 | None}` 재검사 뒤 이 층에 씀 → `{씀, 줄}`."""
|
||||
"""`{열쇠: 줄 | None}` 재검사 뒤 이 층에 씀 → `{씀}`(장부 화면 · M01 승인은 걷음 · 815-2)."""
|
||||
if not isinstance(entries, dict) or not entries or len(entries) > 2000:
|
||||
raise _bad("저장할 줄이 없음")
|
||||
known = _known()
|
||||
clean = {str(k): None if v is None else check(str(k), v, known) for k, v in entries.items()}
|
||||
return {"씀": write(folder, clean, who), "줄": rows(folder, True)}
|
||||
return {"씀": write(folder, clean, who)}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
엑셀 보기 머리 · 목록 = `View.listing`(줄 표지) · 옮기기 = [내 일위대가로] `hopyo-build-all {번}`(807-1).
|
||||
옛 판정 · 검산 · 옮기기 · 근거 제안 길(785-5 `hopyo-judge` · `check` · `move` · `price` · 793-3a `hopyo-basis`)은
|
||||
809-1 에서 지움(요구 703 — 기초로 바꾸기 없음).
|
||||
그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 장부 · 모두 고정 · 붙여넣기 · 다시 잇기 ·
|
||||
그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 모두 고정 · 붙여넣기 · 다시 잇기 ·
|
||||
구조물 조합 · 칠 = B09 · M01 이 씀) · 옛 맞춤 길(호표 하나 맞춤 · 미리 셈 · 실무 호표 · 확인 만들기 · 안 이은 줄 ·
|
||||
단가산출 만들기)은 785-7 에서 지움.
|
||||
층 권한 = `Router_Items._root` · ⚠ 등록은 `main.py`(로그인만).
|
||||
@@ -25,13 +25,11 @@ from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
|
||||
from M02_MasterTemplete import M02_Hopyo_Forget as hopyo_forget
|
||||
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
|
||||
from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Marks as hopyo_marks
|
||||
from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_
|
||||
from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste
|
||||
from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
@@ -82,13 +80,9 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
|
||||
grid = hopyo_grid.read(name, data, parsed)
|
||||
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
|
||||
grid = None
|
||||
row = hopyo_store.save(store.root(), name, parsed, who, grid, mark)
|
||||
cannot = from_hopyo.keep(
|
||||
store.root(), row["키"], name, data
|
||||
) # 산근 시트(793-3b 가져온 식 열기)
|
||||
return (
|
||||
{**row, "읽기불가": cannot} if cannot else row
|
||||
) # 797-1 가져온 식 못 씀 사유 → 화면 토스트
|
||||
# 산근 시트(793-3b 가져온 식 열기) = HP 를 목록에 보이기 전에 보관(814-1) · 못 씀 사유 → 화면 토스트(797-1)
|
||||
keep = lambda key: from_hopyo.keep(store.root(), key, name, data) # noqa: E731
|
||||
return hopyo_store.save(store.root(), name, parsed, who, grid, mark, keep)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo")
|
||||
@@ -211,35 +205,6 @@ def hopyo_make_all(
|
||||
return _call(folder, hopyo_build.build_all, *args)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo-ledger")
|
||||
def hopyo_ledger_read(
|
||||
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""별칭 장부 — 이 층 + 위 층 줄 `{열쇠, 원래, 갈래, 짝, 누가, 때, 처음파일, 층, 주인, 고침}`
|
||||
(고침 = 이 층 · 쓰기 권한)."""
|
||||
folder = _root(session, layer, owner)
|
||||
writable = access.grant(session, layer, owner).write
|
||||
return {"줄": _call(folder, hopyo_ledger.rows, folder, writable)}
|
||||
|
||||
|
||||
class LedgerBody(_Strict):
|
||||
"""`줄` = {열쇠: 장부 줄 | null(지움)} — 이 층만 · 서버가 짝을 다시 검사."""
|
||||
|
||||
줄: dict[str, Any]
|
||||
|
||||
|
||||
@router.put("/layers/{layer}/hopyo-ledger")
|
||||
def hopyo_ledger_write(
|
||||
layer: Layer,
|
||||
body: LedgerBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""별칭 장부 [저장] — 고친 줄 · 지운 줄(층 쓰기) → `{씀, 줄}`."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, hopyo_ledger.put, folder, body.줄, _who(session))
|
||||
|
||||
|
||||
@router.delete("/layers/{layer}/hopyo/{key}")
|
||||
def hopyo_delete(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
|
||||
@@ -20,6 +20,7 @@ from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
|
||||
from M02_MasterTemplete import M02_Machine_Find as mfind
|
||||
|
||||
mm, mp = hm.mm, hm.mp
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
@@ -65,12 +66,19 @@ def _rows(table: str) -> dict[str, dict[str, Any]]:
|
||||
|
||||
def m01_picks(kind: str, line: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""갈래 하나 → M01 고른 것 후보(LB · MT · MO · EQ · FX) — 유일 짝이면 하나 · 같은 글 한 번."""
|
||||
if kind == "기계": # 812-9 기계 찾기 — 별칭 장부가 바뀌므로 lru 밖(찾기가 기계표를 기억)
|
||||
got = mfind.find(line.get("이름"), line.get("규격"))
|
||||
rows = [] if got["판정"] == mfind.NONE else got["후보"]
|
||||
unit = str(line.get("단위") or "") or "hr"
|
||||
return [pick("기계", x["키"], x["이름"], x.get("규격"), unit) for x in rows]
|
||||
text = tuple(str(line.get(k) or "") for k in ("이름", "규격", "단위"))
|
||||
return copy.deepcopy(_m01_picks(kind, *text, id(m01.loaded()[1])))
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[dict[str, Any]]:
|
||||
if kind in ("인력", "재료") and (named := _alias(kind, name, spec)):
|
||||
return named # 815-1 K2 — M01 줄 「별칭」 칸 먼저
|
||||
if kind == "인력":
|
||||
trade = est.trade(name)
|
||||
hit = [r for r in _rows("LB").values() if trade and r.get("이름") == trade]
|
||||
@@ -80,13 +88,6 @@ def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[
|
||||
]
|
||||
got = elem.labor(str(name or ""))
|
||||
return [pick("인력", x["키"], x["이름"], "", "인", 단가=x["단가"]) for x in got]
|
||||
if kind == "기계":
|
||||
text = f"{name} {spec}"
|
||||
one = elem.machine(text)
|
||||
rows = [one] if one else []
|
||||
got = [{"키": r["키"], "이름": r["이름"], "규격": r.get("규격")} for r in rows]
|
||||
got = got or elem.machines(text)
|
||||
return [pick("기계", x["키"], x["이름"], x.get("규격"), unit or "hr") for x in got]
|
||||
if kind == "유가":
|
||||
return [
|
||||
pick("유가", x["키"], x["이름"], x["규격"], x["단위"], 단가=x["단가"])
|
||||
@@ -112,6 +113,23 @@ def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[
|
||||
return []
|
||||
|
||||
|
||||
def _alias(kind: str, name: str, spec: str) -> list[dict[str, Any]]:
|
||||
"""M01 별칭 칸 이음(`M02_Elem_Alias`) — 인력 = 이름 · 재료 = 이름 + 규격 › 표기 정규화 정확 일치 ·
|
||||
같은 품목 지역 줄은 하나."""
|
||||
from M02_MasterTemplete import M02_Elem_Alias as ea
|
||||
|
||||
table = "LB" if kind == "인력" else ea.MARKET
|
||||
keys = ea.keys(table, name, "" if kind == "인력" else spec)
|
||||
if kind == "재료" and len(keys) > 1:
|
||||
keys = [k] if (k := ea.one({"이름": name, "규격": spec}, keys)) else keys
|
||||
if kind == "재료" and not keys: # 815-1 K3 — 표기 정규화 정확 일치(바탕 이름 길보다 먼저)
|
||||
keys = [k] if (k := ea.one({"이름": name, "규격": spec}, ea.exact(name, spec))) else []
|
||||
rows = [(k, ea.row(table, k)) for k in keys]
|
||||
if kind == "인력":
|
||||
return [pick("인력", k, r.get("이름"), "", "인", 단가=_plain(r.get("값"))) for k, r in rows]
|
||||
return [pick("재료", k, r.get("이름"), r.get("규격"), r.get("단위")) for k, r in rows]
|
||||
|
||||
|
||||
# ── M02 층 SK · SC(같은 이름 · 참고) ────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ from common_util.common_util_time import utc_now_text
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||||
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Hopyo_Units as units
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
@@ -144,9 +143,8 @@ class Maker:
|
||||
self._units: units.Units | None = None
|
||||
|
||||
def units(self) -> units.Units:
|
||||
if self._units is None:
|
||||
books = ledger.books(self.source)
|
||||
self._units = units.Units(books, ledger.doc_id(self.source, self.key))
|
||||
if self._units is None: # 815-1 — 옛 별칭 장부 안 읽음(M01 별칭 칸은 Units 가 읽음)
|
||||
self._units = units.Units()
|
||||
return self._units
|
||||
|
||||
def ref_of(self, sub: dict[str, Any]) -> dict[str, Any] | None:
|
||||
|
||||
@@ -124,8 +124,10 @@ def save(
|
||||
who: Any,
|
||||
grid: dict[str, Any] | None = None,
|
||||
mark: str | None = None,
|
||||
first: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄 · `mark` = 올린 엑셀 지문."""
|
||||
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄 · `mark` = 올린 엑셀 지문 ·
|
||||
`first(키)` = HP 를 목록에 보이기 전에 할 일(산근 보관 — 올린 직후 409 막음 · 814-1) · 돌려준 값 = 답 `읽기불가`."""
|
||||
with _LOCK:
|
||||
have = [
|
||||
int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem))
|
||||
@@ -145,9 +147,14 @@ def save(
|
||||
if grid is not None:
|
||||
book = {**grid["책"], "열": key}
|
||||
atomic_write_json(_grid_path(folder, key), {**grid, "책": book})
|
||||
cannot = first(key) if first else None
|
||||
atomic_write_json(_path(folder, key), doc)
|
||||
_put_done(folder, log)
|
||||
return {**brief(doc), **({"지문": mark} if mark else {})}
|
||||
return {
|
||||
**brief(doc),
|
||||
**({"지문": mark} if mark else {}),
|
||||
**({"읽기불가": cannot} if cannot else {}),
|
||||
}
|
||||
|
||||
|
||||
def grid(folder: str | Path, key: str) -> dict[str, Any]:
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
L 노무 = 직종 LB(이름 · 옛이름) · 같은 이름 여럿 = 건설업 먼저(작업반장 · 요구 624-1) · 단위 `{노임}`
|
||||
X 기계 = EQ(`Fixed.Linker._machine` · 778-1 괄호 낱말) → 기계경비 기초 로직(`mach_logics` ·
|
||||
778-2) · 단위 `{기계, 로직, 할증?}` · 없는 규격 = 가까운 규격 후보만(`Elem.machines` · 자동 고름 없음)
|
||||
M 자재 = MT(`material` — 별칭 장부 › 이름 · 규격 같음 › 바탕 이름 · 숫자 규격 같음(치수 차례 · 구분자
|
||||
M 자재 = MT(`material` — M01 별칭 칸 › 이름 · 규격 같음 › 바탕 이름 · 숫자 규격 같음(치수 차례 · 구분자
|
||||
무관) · 글자 든 규격 = 후보로만) · 손료율 = 방법 「수량 × 손료율 × 단가」 · 별도 = 단위 `{자재?,
|
||||
별도계상}` · 단위 `{자재}`
|
||||
R 율 = 778-4 꼴 `{기준, 비목}` — 기준 = `기준꼴`(인력 · 재료 묶음 · 노무비 · 줄 목록 `{행: [...]}`) ·
|
||||
우리에 없는 율 항목(설치비 · 제경비)도 율 줄 · 기준 못 찾음 = 고를 것(글 기준 제안)
|
||||
S 하위 = `{하위 {종류 단산|일위, 번}}`(SK · SC 참조) · E 값 = `{값: 금액}`(고정 금액 줄)
|
||||
별칭 장부 짝(자재) = 숫자보다 먼저. 서버 단독 · 읽기만.
|
||||
M01 줄 「별칭」 칸(자재 · 노임 · 기계 = 기계 찾기 안) = 숫자보다 먼저(815-1). 서버 단독 · 읽기만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,7 +22,7 @@ from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||||
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Elem_Alias as ea
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||||
from M02_MasterTemplete import M02_Hopyo_Shares as shares
|
||||
@@ -109,6 +109,16 @@ def material(line: dict[str, Any], got: dict[str, Any], full: bool = True) -> di
|
||||
found = hm._exact(me, "자재", got)
|
||||
if len(found) == 1:
|
||||
return {"상태": LINKED, "찾음": found, "글": "이름 · 규격 같음", "꼬리표": tags, "후보": []}
|
||||
hits = ea.exact(
|
||||
me.get("이름"), me.get("규격")
|
||||
) # 815-1 K3 표기 정규화 같음 한 줄(지역 변형 = 아래 길)
|
||||
if len(hits) == 1:
|
||||
key = hits[0]
|
||||
one = ea.row(ea.MARKET, key)
|
||||
found = [
|
||||
{"자재": key, "이름": one.get("이름"), "규격": one.get("규격"), "단위": one.get("단위")}
|
||||
]
|
||||
return {"상태": LINKED, "찾음": found, "글": "표기 정규화 같음", "꼬리표": tags, "후보": []}
|
||||
tries = [me, *_split(me)]
|
||||
for one in tries:
|
||||
sure, more = hmat.sure(one)
|
||||
@@ -150,7 +160,7 @@ def _text_base(one: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
class Units:
|
||||
"""한 번 잇기 — M01 · 목록 · 장부를 한 번 읽음(`books` = `Ledger.books` · 없으면 장부 안 봄)."""
|
||||
"""한 번 잇기 — M01 · 목록을 한 번 읽음(`books` · `doc` = 옛 장부 자리 · 815-1 뒤 안 씀)."""
|
||||
|
||||
def __init__(self, books: list | None = None, doc: str | None = None) -> None:
|
||||
self.linker = hfix.Linker(by_name=True)
|
||||
@@ -168,6 +178,9 @@ class Units:
|
||||
return {"방법": how, "후보": [], "꼬리표": [], "글": "", **got}
|
||||
|
||||
def _L(self, one: dict[str, Any]) -> dict[str, Any]:
|
||||
named = ea.keys("LB", one.get("직종") or one.get("이름")) # 815-1 M01 별칭 칸 먼저
|
||||
if len(named) == 1:
|
||||
return {"상태": LINKED, "단위": {"노임": named[0]}, "꼬리표": ["별칭"]}
|
||||
name = shares.trade(one.get("직종") or one.get("이름")) or ""
|
||||
rows = self.lb.get(name, [])
|
||||
cands = [{"키": str(r.get("키")), "갈래": "노임", "이름": r.get("이름"),
|
||||
@@ -211,7 +224,7 @@ class Units:
|
||||
pair = self._alias(line)
|
||||
if pair:
|
||||
unit = {"자재": pair} | ({"별도계상": True} if apart else {})
|
||||
return {"상태": LINKED, "방법": how, "단위": unit, "꼬리표": ["별칭 장부 짝"]}
|
||||
return {"상태": LINKED, "방법": how, "단위": unit, "꼬리표": ["별칭"]}
|
||||
got = material(line, self.got)
|
||||
apart = apart or hmat.APART in got["꼬리표"]
|
||||
found = [r for r in got["찾음"] if r.get("자재")]
|
||||
@@ -231,14 +244,9 @@ class Units:
|
||||
"글": got["글"], "꼬리표": got["꼬리표"]} # fmt: skip
|
||||
|
||||
def _alias(self, line: dict[str, Any]) -> str | None:
|
||||
"""별칭 장부 자재 짝(자동 정확 열쇠 · 하나) — 숫자보다 먼저."""
|
||||
if self.books is None:
|
||||
return None
|
||||
got = ledger.recall(line, "", self.books, doc=self.doc)
|
||||
if not got or got.get("제안") or got.get("규격다름") or got.get("갈래") != "짝":
|
||||
return None
|
||||
pairs = [p.get("자재") for p in got.get("짝") or []]
|
||||
return pairs[0] if len(pairs) == 1 and pairs[0] else None
|
||||
"""M01 자재 줄 「별칭」 칸(815-1 · 이름 + 규격 · 같은 품목 지역 줄은 하나) — 숫자보다 먼저."""
|
||||
keys = ea.keys(ea.MARKET, line.get("이름"), line.get("규격"))
|
||||
return keys[0] if len(keys) == 1 else ea.one(line, keys) if keys else None
|
||||
|
||||
def _R(self, one: dict[str, Any]) -> dict[str, Any]:
|
||||
part = rate_part(one.get("이름"))
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from M01_MasterData import M01_MasterData_Parts as parts
|
||||
from M01_MasterData import M01_MasterData_Router as m01
|
||||
from M02_MasterTemplete import M02_Machine_Hourly as machine
|
||||
|
||||
@@ -72,3 +73,9 @@ def post_text(body: m01.CalcBody) -> dict:
|
||||
def get_machine_hourly(key: str) -> dict:
|
||||
"""기계(EQ) 한 시간 값 노 · 재 · 경(736-3 · [항목 추가] 기계 고정 줄 단가)."""
|
||||
return machine.hourly(key)
|
||||
|
||||
|
||||
@router.get("/machine-parts")
|
||||
def get_machine_parts(no: str) -> dict:
|
||||
"""줄 쌓기 기계 넣기(813-2) — 기계 원문번호 → 그 기종 작업량 틀(문서째)."""
|
||||
return m01._call(parts.of_machine, no)
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""M02 기계 찾기(PLAN 812-9 · 요구 718 · 719) — 가져온 기계 글(이름 · 규격) → 우리 EQ · 서버 단독.
|
||||
|
||||
과녁 = M01 기계표 전체(EQ) · 부르는 곳 = 불러오기 이음(`M02_Hopyo_Row.m01_picks` · `M02_San_Emit._m01`) ·
|
||||
일위대가 호표(`M02_Hopyo_Elem.machine(s)` · `M02_Hopyo_Fixed._eqs`).
|
||||
차례:
|
||||
1. 별칭(M01 기계 줄 「별칭」 칸 · 815-1 `M02_Elem_Alias`) — 「이름 규격」 통째 별칭 = 그 EQ(여럿 =
|
||||
후보 여럿) · 이름만 별칭 = 그 EQ 기종으로 바꿔 2 로
|
||||
2. 기종 = EQ 이름(괄호 뗌) · 괄호 붙임 · 상세구분과 같은 글 › 그 글을 품은 EQ(표기 정규화 `norm`)
|
||||
3. 괄호 낱말 — 기계표 괄호에 있는 낱말(무한궤도 · 타이어 · 자주식 …)만 거름 · 표에 없는 낱말(발파 · 리핑)은 안 봄
|
||||
4. 규격 = 수 + 단위(범위 · 여럿) 모두 EQ 규격에 같은 값(단위 한쪽 없음 = 봄) › 없으면 EQ 범위 안
|
||||
하나 = 이음 · 여럿 = 「후보 여럿」 · 0 = 「못 찾음」(가까운 규격으로 대신 잇지 않음)
|
||||
조합 기계(「+」 · 괄호 속 다른 기종 · 「…조합」 · 「덤프트럭(자동덮개)」) = `parts` 로 갈라 각각 찾음 ·
|
||||
규격 없는 쪽은 앞 쪽 규격.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
# 소리만 다른 표기 → 한 글(양쪽에 같이 씀 · 차례대로)
|
||||
SOUND = (
|
||||
("로우더", "로더"), ("로울러", "롤러"), ("브레이카", "브레이커"), ("브레카", "브레이커"),
|
||||
("굴삭기", "굴착기"), ("굴삭", "굴착"), ("불도우저", "불도저"), ("램머", "래머"),
|
||||
("크로울러", "크롤러"), ("콤프레샤", "공기압축기"), ("컴프레셔", "공기압축기"),
|
||||
("컴프레서", "공기압축기"), ("컴팩터", "콤팩터"), ("타이어형", "타이어"), ("젯트", "제트"),
|
||||
("후렉시블", "플렉시블"), ("스프레야", "스프레이어"), ("코어", "코아"),
|
||||
) # fmt: skip
|
||||
_UNIT = (
|
||||
("m3/min", "m3/min"), ("m3/분", "m3/min"), ("m3/hr", "m3/hr"), ("m3/h", "m3/hr"),
|
||||
("km/hr", "km/hr"), ("km/h", "km/hr"), ("l/min", "l/min"), ("t/hr", "t/hr"),
|
||||
("kg/cm2", "kg/cm2"), ("kg/min", "kg/min"), ("m3", "m3"), ("톤용", "ton"), ("ton", "ton"),
|
||||
("톤", "ton"), ("t", "ton"), ("kw", "kw"), ("mm", "mm"), ("cm", "cm"), ("kg", "kg"),
|
||||
("km", "km"), ("m", "m"), ("l", "l"), ("hp", "hp"), ("amp", "a"), ("cc", "cc"), ('"', "in"),
|
||||
("inch", "in"), ("인치", "in"),
|
||||
) # fmt: skip
|
||||
_NUM = r"\d+(?:\.\d+)?"
|
||||
_TOKEN = re.compile(
|
||||
rf"({_NUM})(?:\s*([~∼\-])\s*({_NUM}))?\s*("
|
||||
+ "|".join(re.escape(u) for u, _ in _UNIT)
|
||||
+ r")?(?![a-z])"
|
||||
)
|
||||
_PAREN = re.compile(r"[(\[]([^()\[\]]*)[)\]]")
|
||||
_EXTRA = re.compile(
|
||||
r"[:,]?\s*\(?할증율?[^)]*\)?"
|
||||
) # 「0.2㎥:할증120%」 · 「(할증125%)」 = 율(규격 아님)
|
||||
NOTE = "참고 · "
|
||||
LINKED, ALIAS, MANY, NONE = "이어짐", "별칭", "후보 여럿", "못 찾음"
|
||||
SHOW = 8 # 후보 글 · 목록 상한
|
||||
|
||||
|
||||
def norm(text: Any) -> str:
|
||||
"""표기 정규화 — NFKC · 작은 글자 · 빈칸 뺌 · 소리 표기 한 글."""
|
||||
t = re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(text or "")).lower())
|
||||
for old, new in SOUND:
|
||||
t = t.replace(old, new)
|
||||
return t
|
||||
|
||||
|
||||
def _clean(text: Any) -> str:
|
||||
t = unicodedata.normalize("NFKC", str(text or "")).lower()
|
||||
t = _EXTRA.sub(" ", t)
|
||||
return re.sub(r"(?<=\d),(?=\d{3}(?!\d))", "", t) # 천 자리 쉼표
|
||||
|
||||
|
||||
def tokens(text: Any) -> list[tuple[float, float, str | None]]:
|
||||
"""규격 글 → [(아래, 위, 단위)] — 범위 「10~12」 · 여럿 「32m,80~95㎥/hr」 · 단위 없음 = None."""
|
||||
out = []
|
||||
for m in _TOKEN.finditer(_clean(text)):
|
||||
lo, sep, hi, unit = float(m.group(1)), m.group(2), m.group(3), m.group(4)
|
||||
top = float(hi) if hi else lo
|
||||
if sep == "-" and top <= lo: # 「50-2.6m」 = 범위 아님 — 앞 수만
|
||||
top = lo
|
||||
canon = dict(_UNIT).get(unit) if unit else None
|
||||
if canon == "in": # 인치 = ㎝(×2.54 · 우리 표는 ㎝)
|
||||
lo, top, canon = round(lo * 2.54, 6), round(top * 2.54, 6), "cm"
|
||||
out.append((lo, top, canon))
|
||||
return out
|
||||
|
||||
|
||||
def _unit_ok(a: str | None, b: str | None) -> bool:
|
||||
return a is None or b is None or a == b or {a, b} <= {"ton", "t/hr"}
|
||||
|
||||
|
||||
def spec_fit(mine: list, theirs: list) -> str | None:
|
||||
"""가져온 규격 수가 EQ 규격에 다 있음 = 「같음」 · EQ 범위 안 = 「안」 · 아니면 None."""
|
||||
if not mine:
|
||||
return "같음" if not theirs else None
|
||||
same = all(
|
||||
any(abs(a - c) < 1e-9 and abs(b - d) < 1e-9 and _unit_ok(u, w) for c, d, w in theirs)
|
||||
for a, b, u in mine
|
||||
)
|
||||
if same:
|
||||
return "같음"
|
||||
inside = all(
|
||||
any(c - 1e-9 <= a and b <= d + 1e-9 and c < d and _unit_ok(u, w) for c, d, w in theirs)
|
||||
for a, b, u in mine
|
||||
)
|
||||
return "안" if inside else None
|
||||
|
||||
|
||||
def _words(inner: str) -> list[str]:
|
||||
return [norm(w) for w in re.split(r"[\s,;·/]+", inner) if w.strip()]
|
||||
|
||||
|
||||
def split_name(text: Any) -> tuple[str, list[list[str]], list[str]]:
|
||||
"""이름(+규격) 글 → (기종 글 정규화, 괄호 낱말 묶음들, 규격 조각들) — 괄호 속 수 = 규격."""
|
||||
raw = _clean(text)
|
||||
groups, specs = [], []
|
||||
for inner in _PAREN.findall(raw):
|
||||
bits = [b for b in re.split(r"[,;]", inner) if b.strip()]
|
||||
specs += [b for b in bits if re.search(r"\d", b)]
|
||||
groups.append([w for b in bits if not re.search(r"\d", b) for w in _words(b)])
|
||||
base = _PAREN.sub(" ", raw)
|
||||
specs += [m.group(0) for m in _TOKEN.finditer(base)]
|
||||
base = _TOKEN.sub(" ", base)
|
||||
return norm(base), groups, specs
|
||||
|
||||
|
||||
# ── 기계표 ────────────────────────────────────────────────────────────
|
||||
class Row:
|
||||
__slots__ = ("raw", "key", "base", "detail", "joined", "full", "words", "spec", "said")
|
||||
|
||||
def __init__(self, r: dict) -> None:
|
||||
name = str(r.get("이름") or "")
|
||||
self.raw, self.key = r, str(r.get("키"))
|
||||
inner = _PAREN.findall(unicodedata.normalize("NFKC", name))
|
||||
self.words = {w for x in inner for w in _words(x)}
|
||||
self.base = norm(_PAREN.sub("", unicodedata.normalize("NFKC", name)))
|
||||
self.detail = norm(r.get("상세구분"))
|
||||
self.joined = {w + self.base for w in self.words} | {self.base + w for w in self.words}
|
||||
self.full = norm(name)
|
||||
self.spec = tokens(r.get("규격"))
|
||||
self.said = norm(
|
||||
_TOKEN.sub(" ", _clean(r.get("규격")))
|
||||
) # 규격 글 낱말(「더블」 · 「엔진식」)
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _table(_whole: int) -> tuple[list[Row], frozenset[str], dict[str, Row]]:
|
||||
rows = [Row(r) for r in m01.loaded()[1].numbers.get("EQ", {}).values()]
|
||||
words = frozenset(w for r in rows for w in r.words)
|
||||
return rows, words, {r.key: r for r in rows}
|
||||
|
||||
|
||||
_SEEN: list = [0.0, None] # (때, 표) — M01 판 확인(파일 도장)은 1 초에 한 번
|
||||
|
||||
|
||||
def table() -> tuple[list[Row], frozenset[str], dict[str, Row]]:
|
||||
now = time.monotonic()
|
||||
if _SEEN[1] is None or now - _SEEN[0] > 1.0:
|
||||
_SEEN[:] = [now, _table(id(m01.loaded()[1]))]
|
||||
return _SEEN[1]
|
||||
|
||||
|
||||
def kind_rows(base: str) -> list[Row]:
|
||||
"""기종 글 → EQ 줄 — 이름 · 괄호 붙임 같음 › 상세구분 같음 › 그 글을 품음(첫 층만)."""
|
||||
if len(base) < 2:
|
||||
return []
|
||||
rows = table()[0]
|
||||
for test in (
|
||||
lambda r: base == r.base or base in r.joined,
|
||||
lambda r: base == r.detail,
|
||||
lambda r: base in r.full or base in r.detail,
|
||||
):
|
||||
got = [r for r in rows if test(r)]
|
||||
if got:
|
||||
return got
|
||||
return []
|
||||
|
||||
|
||||
def _kind_word(word: str, base: str) -> str | None:
|
||||
"""괄호 낱말이 다른 기종(조합)이면 그 기종 글 — 「부착용집게」 · 「브레이커조합」 · 「자동덮개」."""
|
||||
w = word.removesuffix("조합")
|
||||
if len(w) < 2 or w == base:
|
||||
return None
|
||||
rows = table()[0]
|
||||
if any(w in (r.base, r.detail) for r in rows):
|
||||
return w
|
||||
if any(r.base.startswith(base + w) for r in rows):
|
||||
return base + w
|
||||
return None
|
||||
|
||||
|
||||
# ── 조합 기계 ─────────────────────────────────────────────────────────
|
||||
def parts(name: Any, spec: Any = "") -> list[tuple[str, str]]:
|
||||
"""기계 글 → [(이름, 규격)] — 「+」 · 괄호 속 다른 기종이면 둘 이상 · 규격 없는 쪽 = 앞 쪽 규격 ·
|
||||
하나면 받은 그대로."""
|
||||
text = f"{name or ''} {spec or ''}".strip()
|
||||
known = table()[1]
|
||||
out: list[tuple[str, str]] = []
|
||||
for piece in [p for p in text.split("+") if p.strip()] or [text]:
|
||||
main, base, extra = _clean(piece), split_name(piece)[0], []
|
||||
for m in _PAREN.finditer(_clean(piece)):
|
||||
bits = [b for b in re.split(r"[,;]", m.group(1)) if b.strip()]
|
||||
words = [w for b in bits if not re.search(r"\d", b) for w in _words(b)]
|
||||
kind = next((k for w in words if w not in known and (k := _kind_word(w, base))), None)
|
||||
if kind:
|
||||
main = main.replace(m.group(0), " ")
|
||||
extra.append((kind, ",".join(b for b in bits if re.search(r"\d", b))))
|
||||
bare = _TOKEN.sub(
|
||||
" ", _PAREN.sub(lambda m: " " if re.search(r"\d", m.group(1)) else m.group(0), main)
|
||||
)
|
||||
own = " ".join(split_name(main)[2])
|
||||
out += [(" ".join(bare.split()).strip(" ,"), own), *extra]
|
||||
if len(out) < 2:
|
||||
return [(str(name or ""), str(spec or ""))]
|
||||
first = next((s for _, s in out if s), "")
|
||||
return [(n, s or first) for n, s in out]
|
||||
|
||||
|
||||
# ── 별칭(M01 기계 줄 「별칭」 칸 · 815-1) ───────────────────────────────────
|
||||
def alias(name: Any, spec: Any) -> list[str]:
|
||||
"""M01 기계 줄 별칭(`M02_Elem_Alias`) — 규격이 있으면 「이름 규격」 통째 별칭(규격 속 할증 글은 뺌) ·
|
||||
규격 빔 = 이름 별칭 · 없으면 []."""
|
||||
from M02_MasterTemplete import M02_Elem_Alias as ea
|
||||
|
||||
rows = ea.book()["별칭"].get("EQ", {})
|
||||
return list(rows.get(ea.am.norm(name) + ea.am.norm(_clean(spec)), []))
|
||||
|
||||
|
||||
# ── 찾기 ──────────────────────────────────────────────────────────────
|
||||
def _brief(r: Row) -> dict[str, Any]:
|
||||
raw = r.raw
|
||||
return {"키": r.key, "이름": raw.get("이름"), "규격": raw.get("규격") or "",
|
||||
"원문번호": raw.get("원문번호")} # fmt: skip
|
||||
|
||||
|
||||
def _query(rows: list[Row], base: str) -> str:
|
||||
names = {str(r.raw.get("이름")) for r in rows}
|
||||
if len(names) == 1:
|
||||
return names.pop()
|
||||
details = {str(r.raw.get("상세구분")) for r in rows}
|
||||
return details.pop() if len(details) == 1 else base
|
||||
|
||||
|
||||
def _ans(kind: str, rows: list[Row], query: str, why: str = "") -> dict[str, Any]:
|
||||
key = rows[0].key if kind in (LINKED, ALIAS) else None
|
||||
return {"판정": kind, "키": key, "후보": [_brief(r) for r in rows[:SHOW]], "찾기어": query,
|
||||
"까닭": why, "수": len(rows)} # fmt: skip
|
||||
|
||||
|
||||
def find(name: Any, spec: Any = "") -> dict[str, Any]:
|
||||
"""기계 하나(조합 아님) → `{판정, 키, 후보, 찾기어, 까닭, 수}`."""
|
||||
rows_by = table()[2]
|
||||
hit = [rows_by[k] for k in alias(name, spec) if k in rows_by]
|
||||
if hit: # 별칭 하나 = 그 EQ · 여럿 = 후보 여럿
|
||||
if len(hit) == 1:
|
||||
return _ans(ALIAS, hit, str(hit[0].raw.get("이름")))
|
||||
return _ans(MANY, hit, _query(hit, hit[0].base), f"후보 {len(hit)}")
|
||||
base = split_name(name)[0]
|
||||
_, groups, specs = split_name(f"{name or ''} {spec or ''}") # 규격 괄호 낱말 「3.6m(일반용)」
|
||||
groups.append(
|
||||
_words(_TOKEN.sub(" ", _PAREN.sub(" ", _clean(spec))))
|
||||
) # 규격 글 낱말 「탑승유압식」
|
||||
named = [rows_by[k] for k in alias(name, "") if k in rows_by]
|
||||
if named and len({r.base for r in named}) == 1: # 이름만 별칭 = 그 EQ 기종으로
|
||||
base = named[0].base
|
||||
mine = tokens(" ".join(specs))
|
||||
kinds = kind_rows(base)
|
||||
known = table()[1]
|
||||
rows = kinds
|
||||
for w in (w for g in groups for w in g):
|
||||
if w in known: # 기계표 괄호 낱말 = 꼭 맞아야 함
|
||||
rows = [r for r in rows if w in r.full]
|
||||
elif len(w) > 1 and (kept := [r for r in rows if w in r.said]): # 규격 글 낱말 = 좁힐 때만
|
||||
rows = kept
|
||||
if not mine: # 규격 수 없음 = 그 기종 전부(괄호 · 규격 낱말로 거른 것)
|
||||
got = rows
|
||||
else:
|
||||
same = [r for r in rows if spec_fit(mine, r.spec) == "같음"]
|
||||
got = same or [r for r in rows if spec_fit(mine, r.spec) == "안"]
|
||||
if len(got) == 1: # 이름만 별칭으로 기종을 정했으면 「별칭」
|
||||
return _ans(ALIAS if named else LINKED, got, str(got[0].raw.get("이름")))
|
||||
if got:
|
||||
return _ans(MANY, got, _query(got, base), f"후보 {len(got)}")
|
||||
if not kinds:
|
||||
return _ans(NONE, [], base, "기종 모름 — 별칭")
|
||||
why = "우리 표에 없음(규격)" if rows else "우리 표에 없음(괄호 낱말)"
|
||||
return _ans(
|
||||
NONE, sorted(rows or kinds, key=lambda r: _gap(mine, r.spec)), _query(kinds, base), why
|
||||
)
|
||||
|
||||
|
||||
def _gap(mine: list, theirs: list) -> float:
|
||||
"""가까운 규격 차례(보여주기만) — 첫 수끼리 비."""
|
||||
if not mine or not theirs or mine[0][0] <= 0 or theirs[0][0] <= 0:
|
||||
return math.inf
|
||||
return abs(math.log(mine[0][0] / theirs[0][0]))
|
||||
|
||||
|
||||
def find_all(name: Any, spec: Any = "") -> list[dict[str, Any]]:
|
||||
"""기계 글 → 부분마다 `find` + {이름, 규격}(조합 = 둘 이상)."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for n, s in parts(name, spec):
|
||||
if out and s and not split_name(n)[2] and s == out[0]["규격"] and out[0]["키"]:
|
||||
s = str(out[0]["후보"][0]["규격"]) # 앞 줄 규격을 받은 쪽 = 이은 우리 규격(단위까지)
|
||||
out.append({**find(n, s), "이름": n, "규격": s})
|
||||
return out
|
||||
|
||||
|
||||
def mark(got: dict[str, Any], name: Any, spec: Any) -> str | None:
|
||||
"""못 이은 기계 표지 글(「참고 · 」 머리) — 이음 · 별칭 = None."""
|
||||
said = f"「{' '.join(x for x in (str(name or ''), str(spec or '')) if x)}」"
|
||||
if got["판정"] == MANY:
|
||||
names = " · ".join(f"{c['이름']} {c['규격']}".strip() for c in got["후보"])
|
||||
return f"{NOTE}기계 후보 {got['수']} — 고르기 {said}: {names}"
|
||||
if got["판정"] == NONE:
|
||||
return f"{NOTE}기계 못 찾음 — {said} {got['까닭']}"
|
||||
return None
|
||||
@@ -41,6 +41,32 @@ def _rank(row: dict) -> tuple:
|
||||
return (attach, loss, loss and "보정작업" in names, len(_codes(row)), str(row.get("키")))
|
||||
|
||||
|
||||
def _operated(code: str) -> bool:
|
||||
"""기계표(원문 8-4) 조종원 > 0 — 그 원문번호 기계 가운데 하나라도."""
|
||||
rows = store.loaded()[1].index.get("EQ", {}).values()
|
||||
return any(
|
||||
str(r.get("원문번호")) == code and float((r.get("값") or {}).get("조종원") or 0) > 0
|
||||
for r in rows
|
||||
)
|
||||
|
||||
|
||||
def logic_of(code: str) -> dict | None:
|
||||
"""기계 원문번호 → 그 기계 시간당 경비 로직(짝 차례 첫째) · 없으면 None(813-2 M01 화면 ·
|
||||
불러오기 `M02_Doc_Import.hourly_link` 도 씀). 기계표 조종원 > 0 = 조종원 호표 있는 식 먼저
|
||||
(「조종원 없는 기종」 목록에 겹친 기계 — 진동롤러(핸드가이드식) · 고소작업차 …)."""
|
||||
fit = [r for r in _family() if code in [str(c) for c in _codes(r)]]
|
||||
if not fit:
|
||||
return None
|
||||
op = _operated(code)
|
||||
|
||||
def order(row: dict) -> tuple:
|
||||
has = any(x.get("이름") == "조종원" for x in row.get("호표") or [])
|
||||
rank = _rank(row)
|
||||
return (*rank[:3], op and not has, *rank[3:])
|
||||
|
||||
return min(fit, key=order)
|
||||
|
||||
|
||||
def hourly(key: str) -> dict[str, Any]:
|
||||
"""`{ok, 이름, 규격, 로직, 원문번호, 노무비, 재료비, 경비, 계}` · 못 셈 = `{ok: False, reason}`."""
|
||||
if not key.startswith("EQ"):
|
||||
@@ -50,10 +76,9 @@ def hourly(key: str) -> dict[str, Any]:
|
||||
except cm.mf.FormulaError as e:
|
||||
return {"ok": False, "reason": str(e)}
|
||||
code = str(row.get("원문번호") or "")
|
||||
fit = [r for r in _family() if code in [str(c) for c in _codes(r)]]
|
||||
if not fit:
|
||||
logic = logic_of(code)
|
||||
if not logic:
|
||||
return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"}
|
||||
logic = min(fit, key=_rank)
|
||||
args = "".join(
|
||||
f", {i['이름']}='{(i.get('고르기') or [''])[0]}'"
|
||||
for i in logic.get("입력") or []
|
||||
@@ -78,3 +103,24 @@ def hourly(key: str) -> dict[str, Any]:
|
||||
"원문번호": code,
|
||||
**sums,
|
||||
}
|
||||
|
||||
|
||||
def combo(main: str, part: str) -> tuple[str, str] | None:
|
||||
"""조합 기계(굴착기 + 대형 브레이커 · 부착용 집게 · 814-1) → 우리 조합 식 (키, 부름 글)
|
||||
`로직(GC000271, 기계='…', 브레이커='…', 지역='전국평균', …)` — 두 기계가 그 식 고르기에 있을 때만."""
|
||||
try:
|
||||
codes = [str(store.loaded()[1].get(k).get("원문번호") or "") for k in (main, part)]
|
||||
except cm.mf.FormulaError:
|
||||
return None
|
||||
for row in sorted(_family(), key=lambda r: str(r.get("키"))):
|
||||
ins = row.get("입력") or []
|
||||
attach = [i for i in ins if i.get("이름") not in _PLAIN and i.get("고르기")]
|
||||
if len(attach) != 1 or codes[0] not in [str(c) for c in _codes(row)]:
|
||||
continue
|
||||
if codes[1] not in [str(c) for c in attach[0]["고르기"]]:
|
||||
continue
|
||||
given = {"기계": codes[0], attach[0]["이름"]: codes[1]}
|
||||
given |= {i["이름"]: (i.get("고르기") or [""])[0] for i in ins if i["이름"] not in given}
|
||||
args = "".join(f", {k}='{v}'" for k, v in given.items())
|
||||
return str(row["키"]), f"로직({row['키']}{args})"
|
||||
return None
|
||||
|
||||
@@ -34,14 +34,6 @@
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.m02-hopyo-ledger__table td:nth-child(1) {
|
||||
min-width: 7em;
|
||||
}
|
||||
|
||||
.m02-hopyo-ledger__table td:nth-child(2) {
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
/* 우리 로직 칸 — 대상 여럿 = 칸 안 여러 줄(이름 · 키 · 몫 · 배 칸 · [옵션]) */
|
||||
.m02-hopyo__parts {
|
||||
min-width: 11em;
|
||||
@@ -83,15 +75,6 @@
|
||||
margin-left: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */
|
||||
.m02-hopyo-ledger__row.is-upper {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-hopyo-ledger__row.is-edited > td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
/* 엑셀 보기(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 */
|
||||
.m02-hopyo-sheet {
|
||||
display: grid;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* M02_MasterTemplete_Hopyo_Api.ts
|
||||
* 일위대가 실무자료(PLAN 324 · 338) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
|
||||
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로) ·
|
||||
* 목록 · 올림 · 엑셀 보기 머리(809-1 줄 표지 줄상태) · 격자 · 지우기 · 별칭 장부 · 붙여넣기 · 다시 잇기 —
|
||||
* 목록 · 올림 · 엑셀 보기 머리(809-1 줄 표지 줄상태) · 격자 · 지우기 · 붙여넣기 · 다시 잇기 —
|
||||
* [수정] 보기 = `Hopyo_View`(809-1) · 판정 · 검산 · 옮기기 길(785)은 809-1 에서 지움(요구 703).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -97,36 +97,6 @@ export interface HopyoBuiltAll {
|
||||
만듦?: { 번: number; 이름: string; 키: string }[];
|
||||
}
|
||||
|
||||
/** 별칭 장부 짝 하나 */
|
||||
export interface LedgerPair {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
입력값: Record<string, unknown>;
|
||||
배: number;
|
||||
몫: number | null;
|
||||
}
|
||||
|
||||
/** 별칭 장부 한 줄(쓰기 꼴) */
|
||||
export interface LedgerEntry {
|
||||
원래: { 품명: string; 규격: string; 단위: string };
|
||||
갈래: "짝" | "우리에 없음" | "안 씀";
|
||||
짝: LedgerPair[];
|
||||
엑셀조항?: unknown[];
|
||||
}
|
||||
|
||||
/** 별칭 장부 보기 한 줄 — 층 · 고침(이 층 · 쓰기 권한) */
|
||||
export interface LedgerRow extends LedgerEntry {
|
||||
열쇠: string;
|
||||
누가: { id: number; 이름: string | null } | null;
|
||||
때: string | null;
|
||||
처음파일: string | null;
|
||||
층: Layer;
|
||||
주인: string | null;
|
||||
고침: boolean;
|
||||
/** 739-1 M01 승인 상태 — 마스터 줄 = 승인 */
|
||||
상태?: "승인" | "대기" | "반려";
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
/** 지금 화면 층(구조물 조합 층) */
|
||||
export const pageScope = (): HopyoScope => ({ layer: getActiveLayer(), owner: getActiveOwner() });
|
||||
@@ -146,21 +116,6 @@ export const readHopyo = (scope: HopyoScope, key: string): Promise<HopyoView> =>
|
||||
export const readHopyoGrid = (scope: HopyoScope, key: string): Promise<HopyoGrid> =>
|
||||
call(at(scope, `/hopyo-grid/${enc(key)}`));
|
||||
|
||||
export const readLedger = async (scope: HopyoScope): Promise<LedgerRow[]> =>
|
||||
(await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"))).줄;
|
||||
|
||||
/** 별칭 장부 [저장] — 고친 줄 · null(지움) · 이 층만 */
|
||||
export const saveLedger = async (
|
||||
scope: HopyoScope,
|
||||
줄: Record<string, LedgerEntry | null>,
|
||||
): Promise<LedgerRow[]> =>
|
||||
(
|
||||
await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ 줄 }),
|
||||
})
|
||||
).줄;
|
||||
|
||||
/** [끝내기] · 화면 떠남(743-1) — `keepalive` = 페이지를 닫아도 요청은 감 */
|
||||
export const deleteHopyo = (scope: HopyoScope, key: string, keepalive = false): Promise<unknown> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE", keepalive });
|
||||
|
||||
@@ -61,7 +61,7 @@ interface EditAnswer {
|
||||
문서: ImportedDoc;
|
||||
전?: Money4 | null;
|
||||
후?: Money4 | null;
|
||||
이음없음?: { 자리: string; 이름: string }[];
|
||||
이음없음?: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean }[];
|
||||
/** qty 답(797-2) */
|
||||
줄?: QtyRow[];
|
||||
집계?: QtyTally;
|
||||
|
||||
@@ -42,6 +42,7 @@ const TEXT = {
|
||||
Excel: ["엑셀", "Excel"],
|
||||
Now: ["지금", "Now"],
|
||||
Unlinked: ["이음 없는 자원 {n}", "Unlinked resources {n}"],
|
||||
Quote: ["견적 {n}", "Quotes {n}"],
|
||||
Link: ["이음 고르기", "Pick link"],
|
||||
Gap: ["바꾸기 전 {a} → 후 {b} ({d})", "Before {a} → after {b} ({d})"],
|
||||
QtyLinks: ["소요량 · 계수 이음 {n}", "Quantity / coefficient links {n}"],
|
||||
@@ -54,7 +55,7 @@ export const tx = (k: keyof typeof TEXT): string =>
|
||||
export interface PanelState {
|
||||
doc: ImportedDoc;
|
||||
/** 이음 없는 자원(서버 price 답) */
|
||||
unlinked: { 자리: string; 이름: string }[];
|
||||
unlinked: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean }[];
|
||||
/** 소요량 · 계수 이음 집계(서버 qty 답 · 797-2) */
|
||||
qty: QtyTally | null;
|
||||
/** 마지막 단가 바꿈 전후 */
|
||||
@@ -149,12 +150,18 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement {
|
||||
const kids: HTMLElement[] = [createToolbar({ items: head })];
|
||||
if (here && s.unlinked.length) {
|
||||
const items = s.unlinked.map((u) => {
|
||||
const b = createButton({ label: `${u.이름} · ${tx("Link")}`, size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.link(u.자리, u.이름) }); // prettier-ignore
|
||||
const label = `${u.이름}${u.까닭 ? ` (${u.까닭})` : ""} · ${tx("Link")}`; // 못 이은 까닭(814-1)
|
||||
const b = createButton({ label, size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.link(u.자리, u.이름) }); // prettier-ignore
|
||||
b.dataset.importedLink = u.자리;
|
||||
return b;
|
||||
});
|
||||
const tag = createBadge(tx("Unlinked").replace("{n}", String(s.unlinked.length)), "warning");
|
||||
kids.push(createToolbar({ items: [tag, ...items] }));
|
||||
const quotes = s.unlinked.filter((u) => u.견적).length; // 815-1 견적 = 그대로 쓰는 값 · 못 이음 수에서 뺌
|
||||
const left = s.unlinked.length - quotes;
|
||||
const tags = [
|
||||
...(left ? [createBadge(tx("Unlinked").replace("{n}", String(left)), "warning")] : []),
|
||||
...(quotes ? [createBadge(tx("Quote").replace("{n}", String(quotes)), "info")] : []),
|
||||
];
|
||||
kids.push(createToolbar({ items: [...tags, ...items] }));
|
||||
}
|
||||
if (here && s.qty?.자리) {
|
||||
const open = createButton({ label: tx("QtyOpen"), size: 24, variant: "ghost", onClick: a.qty });
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Ledger.ts
|
||||
* 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶.
|
||||
* · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 단위 환산 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만)
|
||||
* · 뜻(359-4 · 머리 한 줄 · 좌측 단추 title) = 엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안
|
||||
* · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [항목 추가] · 단위 환산 · 몫 · [옵션] · [삭제] ·
|
||||
* 위 층 줄 [내 층 복사] · 갈래 글 = 확인 창 드롭다운과 같음(짝 · 로직없음 · 사용안함 · 저장 값 그대로)
|
||||
* · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사)
|
||||
* · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기.
|
||||
* · 739-1 중앙 관리 — 회사 · 개인 층 = 「내 제안 목록」(보기 · 고치기 · [거두기] · 승인 상태 대기 · 승인 · 반려) ·
|
||||
* 자동 짝 = 마스터(M01 별칭 장부 승인)만 · 마스터 층(M01 화면) = 별칭 장부 그대로
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
readLedger,
|
||||
saveLedger,
|
||||
type HopyoScope,
|
||||
type LedgerEntry,
|
||||
type LedgerPair,
|
||||
type LedgerRow,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { dateTimeText } from "../common_util/common_util_time";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
|
||||
import { formatNumber } from "../common_util/common_util_money";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const LEDGER_TITLE = "별칭 장부";
|
||||
/** 별칭 장부 뜻 한 줄(359-4) — 좌측 단추 title · 화면 머리 */
|
||||
export const LEDGER_HINT =
|
||||
"엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안";
|
||||
const KINDS = ["짝", "우리에 없음", "안 씀"] as const;
|
||||
/** 갈래 화면 글 — 확인 창 드롭다운과 같음(저장 값 그대로) */
|
||||
export const KIND_TEXT: Record<string, string> = {
|
||||
짝: "짝",
|
||||
"우리에 없음": "로직없음",
|
||||
"안 씀": "사용안함",
|
||||
};
|
||||
const LAYER_TEXT: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
|
||||
/** 739-1 회사 · 개인 층 장부 화면 제목 */
|
||||
export const MINE_TITLE = "내 제안 목록";
|
||||
const STATE_TONE: Record<string, BadgeTone> = { 승인: "success", 대기: "warning", 반려: "danger" };
|
||||
type Edits = Record<string, LedgerEntry | null>;
|
||||
/** 화면 한 줄 — 서버 줄 위에 고친 것 */
|
||||
export type LedgerView = LedgerRow & { 고친?: boolean };
|
||||
|
||||
export const ledgerKey = (scope: HopyoScope): string =>
|
||||
`m02-hopyo-ledger:${scope.layer}:${scope.owner ?? ""}`;
|
||||
function loadEdits(k: string): Edits {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(k) ?? "{}") as Edits;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function saveEdits(k: string, edits: Edits): void {
|
||||
try {
|
||||
if (Object.keys(edits).length) sessionStorage.setItem(k, JSON.stringify(edits));
|
||||
else sessionStorage.removeItem(k);
|
||||
} catch {
|
||||
/* 캐시 못 씀 = 이 화면에만 남음 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 쓰기 꼴 — 보기 칸(층 · 누가 · 때 …) 뺌 */
|
||||
export const entryOf = (row: LedgerEntry): LedgerEntry => ({
|
||||
원래: row.원래,
|
||||
갈래: row.갈래,
|
||||
짝:
|
||||
row.갈래 === "짝"
|
||||
? row.짝.map((p) => ({ ...p, 입력값: p.입력값 ?? {}, 몫: p.몫 ?? null }))
|
||||
: [],
|
||||
엑셀조항: row.엑셀조항 ?? [],
|
||||
});
|
||||
|
||||
/** 서버 줄 + 고친 것 → 화면 줄 — 이 층 줄은 고친 값 · 지운 것 뺌 · 위 층에서 옮긴 줄은 이 층 줄로 더함 */
|
||||
export function mergeRows(rows: LedgerRow[], edits: Edits, scope: HopyoScope): LedgerView[] {
|
||||
const mine = rows.filter((r) => r.고침);
|
||||
const out: LedgerView[] = [];
|
||||
for (const r of mine) {
|
||||
if (!(r.열쇠 in edits)) out.push(r);
|
||||
else if (edits[r.열쇠]) out.push({ ...r, ...edits[r.열쇠]!, 고친: true });
|
||||
}
|
||||
for (const [key, entry] of Object.entries(edits)) {
|
||||
if (!entry || mine.some((r) => r.열쇠 === key)) continue;
|
||||
out.push({
|
||||
...entry,
|
||||
열쇠: key,
|
||||
누가: null,
|
||||
때: null,
|
||||
처음파일: null,
|
||||
층: scope.layer,
|
||||
주인: scope.owner,
|
||||
고침: true,
|
||||
고친: true,
|
||||
});
|
||||
}
|
||||
return [...out, ...rows.filter((r) => !r.고침)];
|
||||
}
|
||||
|
||||
/** 짝 글 — 「GC000198 × 1 · 몫 0.1 · 소량감률=50」 */
|
||||
export const pairText = (p: LedgerPair): string =>
|
||||
[
|
||||
`${p.로직 ?? p.자재 ?? ""} × ${p.배}`,
|
||||
p.몫 != null ? `몫 ${p.몫}` : "",
|
||||
Object.entries(p.입력값 ?? {})
|
||||
.map(([k, v]) => `${k}=${formatNumber(v)}`)
|
||||
.join(", "),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
/** 찾기 · 갈래 거름 */
|
||||
export const filterRows = (rows: LedgerView[], q: string, kind: string): LedgerView[] => {
|
||||
const want = q.trim().toLowerCase();
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
(!kind || r.갈래 === kind) &&
|
||||
(!want ||
|
||||
`${r.원래.품명} ${r.원래.규격} ${r.짝.map(pairText).join(" ")}`
|
||||
.toLowerCase()
|
||||
.includes(want)),
|
||||
);
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const note = (text: string): HTMLElement =>
|
||||
el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } });
|
||||
|
||||
export interface LedgerOptions {
|
||||
scope: HopyoScope;
|
||||
/** 이 층 쓰기 권한 — 없으면 보기만 */
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
const root = el("div", {
|
||||
className: "m02-hopyo m02-hopyo-ledger",
|
||||
attrs: { "data-hopyo-ledger": "" },
|
||||
});
|
||||
const k = ledgerKey(opt.scope);
|
||||
const master = opt.scope.layer === "system";
|
||||
let edits = loadEdits(k);
|
||||
let rows: LedgerRow[] = [];
|
||||
const search = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__search",
|
||||
attrs: { type: "search", placeholder: "품명 · 규격 · 로직 찾기" },
|
||||
}) as HTMLInputElement;
|
||||
const kind = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: [
|
||||
el("option", { text: "갈래 전체", attrs: { value: "" } }),
|
||||
...KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
const save = createButton({ label: "저장", variant: "filled", size: "sm" });
|
||||
const drop = createButton({ label: "변경 취소", variant: "ghost", size: "sm" });
|
||||
const body = el("tbody");
|
||||
const keep = (): void => {
|
||||
saveEdits(k, edits);
|
||||
paint();
|
||||
};
|
||||
const edit = (r: LedgerView, next: Partial<LedgerEntry>): void => {
|
||||
edits[r.열쇠] = entryOf({ ...entryOf(r), ...next });
|
||||
keep();
|
||||
};
|
||||
|
||||
function pairCell(r: LedgerView, mine: boolean): HTMLElement {
|
||||
const pairs: HTMLElement[] = r.짝.map((p, at) => {
|
||||
const parts: HTMLElement[] = [el("span", { text: pairText(p) })];
|
||||
if (mine) {
|
||||
const times = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: { type: "number", step: "any", min: "0", value: String(p.배) },
|
||||
}) as HTMLInputElement;
|
||||
times.addEventListener("change", () =>
|
||||
edit(r, { 짝: r.짝.map((x, i) => (i === at ? { ...x, 배: Number(times.value) } : x)) }),
|
||||
);
|
||||
parts.push(unitField(times, { from: r.원래.단위, to: "" }));
|
||||
if (p.로직)
|
||||
parts.push(
|
||||
createButton({
|
||||
label: "옵션",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
void fetchLogicInputs([p.로직!]).then(
|
||||
(got) =>
|
||||
openLaborInput({
|
||||
title: p.로직!,
|
||||
logic: got.로직[0] ?? null,
|
||||
values: p.입력값 as never,
|
||||
memo: null,
|
||||
readOnly: false,
|
||||
done: (res) =>
|
||||
edit(r, {
|
||||
짝: r.짝.map((x, i) => (i === at ? { ...x, 입력값: res.입력값 } : x)),
|
||||
}),
|
||||
}),
|
||||
(error) => showToast(why(error), "error"),
|
||||
),
|
||||
}),
|
||||
createButton({
|
||||
label: "×",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => edit(r, { 짝: r.짝.filter((_, i) => i !== at) }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("div", { className: "m02-hopyo__part", children: parts });
|
||||
});
|
||||
if (mine && r.갈래 === "짝")
|
||||
pairs.push(
|
||||
createButton({
|
||||
label: "항목 추가",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
// 736-1 별칭 장부가 가리킬 수 있는 것 = 기초 로직 · 재료 둘(탭 막음)
|
||||
openFindPicker({
|
||||
layer: opt.scope.layer,
|
||||
owner: opt.scope.owner,
|
||||
kinds: ["logic", "material"],
|
||||
logic: (x) => edit(r, { 짝: [...r.짝, { 로직: x.키, 입력값: {}, 배: 1, 몫: null }] }),
|
||||
material: (e) =>
|
||||
edit(r, { 짝: [...r.짝, { 자재: e.코드, 입력값: {}, 배: 1, 몫: null }] }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return el("td", { className: "m02-hopyo__parts", children: pairs });
|
||||
}
|
||||
|
||||
function rowOf(r: LedgerView): HTMLElement {
|
||||
const mine = opt.canWrite && r.고침;
|
||||
const kindCell = mine
|
||||
? (() => {
|
||||
const s = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })),
|
||||
}) as HTMLSelectElement;
|
||||
s.value = r.갈래;
|
||||
s.addEventListener("change", () =>
|
||||
edit(r, { 갈래: s.value as LedgerEntry["갈래"], 짝: s.value === "짝" ? r.짝 : [] }),
|
||||
);
|
||||
return el("td", { children: [s] });
|
||||
})()
|
||||
: td(KIND_TEXT[r.갈래] ?? r.갈래);
|
||||
const acts: HTMLElement[] = [];
|
||||
if (mine)
|
||||
acts.push(
|
||||
createButton({
|
||||
label: master ? "삭제" : "거두기",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
edits[r.열쇠] = null;
|
||||
keep();
|
||||
},
|
||||
}),
|
||||
);
|
||||
else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠])
|
||||
acts.push(
|
||||
createButton({
|
||||
label: "내 층 복사",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
edits[r.열쇠] = entryOf(r);
|
||||
keep();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const when = [r.누가?.이름, dateTimeText(r.때, ""), r.처음파일].filter(Boolean).join(" · ");
|
||||
return el("tr", {
|
||||
className: `m02-hopyo-ledger__row${r.고침 ? "" : " is-upper"}${r.고친 ? " is-edited" : ""}`,
|
||||
attrs: { "data-ledger": r.열쇠 },
|
||||
children: [
|
||||
td(r.원래.품명),
|
||||
td(r.원래.규격),
|
||||
td(r.원래.단위),
|
||||
kindCell,
|
||||
pairCell(r, mine),
|
||||
td(when),
|
||||
td(r.고침 ? "내 층" : (LAYER_TEXT[r.층] ?? r.층)),
|
||||
el("td", {
|
||||
attrs: { "data-state": r.상태 ?? "" },
|
||||
children: r.고친 || !r.상태 ? [] : [createBadge(r.상태, STATE_TONE[r.상태])],
|
||||
}),
|
||||
el("td", { className: "m02-hopyo__acts", children: acts }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
const all = mergeRows(rows, edits, opt.scope);
|
||||
const shown = filterRows(all, search.value, kind.value);
|
||||
const n = Object.keys(edits).length;
|
||||
save.querySelector(".ui-btn__label")!.textContent = n ? `저장 (${n})` : "저장";
|
||||
save.disabled = drop.disabled = !n;
|
||||
body.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.map(rowOf)
|
||||
: [el("tr", { children: [el("td", { text: "장부 줄 없음", attrs: { colspan: "9" } })] })]),
|
||||
);
|
||||
}
|
||||
search.addEventListener("input", paint);
|
||||
kind.addEventListener("change", paint);
|
||||
drop.addEventListener("click", () => {
|
||||
edits = {};
|
||||
keep();
|
||||
});
|
||||
save.addEventListener("click", async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
rows = await saveLedger(opt.scope, edits);
|
||||
edits = {};
|
||||
showToast("장부 저장", "success");
|
||||
keep();
|
||||
} catch (error) {
|
||||
showToast(`저장 못 함 — ${why(error)}`, "error");
|
||||
paint();
|
||||
}
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const stop = showLoading(root);
|
||||
try {
|
||||
rows = await readLedger(opt.scope);
|
||||
} catch (error) {
|
||||
root.replaceChildren(loadingError(why(error), () => void load()));
|
||||
return;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
root.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-toolbar m02-hopyo__bar",
|
||||
children: [
|
||||
el("strong", { text: master ? LEDGER_TITLE : MINE_TITLE }),
|
||||
note(
|
||||
master
|
||||
? `${LEDGER_HINT} · 여기 짝 = 모든 사용자 불러오기에 자동`
|
||||
: `내 층 짝 = 내 제안(다음 불러오기에서 후보 맨 위 · 자동 아님) · M01 별칭 장부 승인 뒤 자동 · ${L("M02_PrecomboTitle")} 생성 때 남음`,
|
||||
),
|
||||
search,
|
||||
kind,
|
||||
...(opt.canWrite ? [save, drop] : []),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table m02-hopyo-ledger__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
"엑셀 품명",
|
||||
"규격",
|
||||
"단위",
|
||||
"갈래",
|
||||
"우리 로직 · 단위 환산 · 몫 · 옵션",
|
||||
"누가 · 때 · 처음 파일",
|
||||
"층",
|
||||
"승인",
|
||||
"",
|
||||
].map((t) => el("th", { text: t })),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
paint();
|
||||
}
|
||||
|
||||
void load();
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 단위 환산 칸 — 「엑셀 1 {from} =」 입력 「{to}」(옛 확인 창 `CheckRows.unitField` 옮김 · 785-7) */
|
||||
function unitField(input: HTMLInputElement, v: { from: string; to: string }): HTMLElement {
|
||||
input.title = L("M02_HopyoUnitTip");
|
||||
const word = (text: string): HTMLElement =>
|
||||
el("span", { className: "m02-hopyo__unit-text", text });
|
||||
return el("span", {
|
||||
className: "m02-hopyo__unit",
|
||||
children: [
|
||||
word(L("M02_HopyoUnitLead").replace("{from}", v.from || "?")),
|
||||
input,
|
||||
...(v.to ? [word(v.to)] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -275,7 +275,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
const lastOf = (sid: string): string =>
|
||||
lastColumn(book.시트.find((x) => x.id === sid) ?? first);
|
||||
const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left };
|
||||
nav = ids.일위대가표 ? Nav.createSheetNav(more) : null;
|
||||
nav = ids.일위대가표 || ids.단가산출표 ? Nav.createSheetNav(more) : null; // 원파일 = 단가산출표 판만(814-1)
|
||||
if (nav) warn.after(nav.root);
|
||||
sk = nav ? createSheetSk({ ...opt, target, nav, side: right, ...(opt.onSkChange ? { onChange: opt.onSkChange } : {}) }) : null; // prettier-ignore
|
||||
sheet = createSpreadsheet(left, book, {
|
||||
@@ -290,6 +290,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
if (no !== null && no !== current) mark(no);
|
||||
},
|
||||
});
|
||||
if (!ids.일위대가표 && ids.단가산출표) sk?.at({ 시트: first.id, 칸: "A1" }); // 일위대가 없음 = 단가산출 목록 먼저
|
||||
nav?.marks(briefs);
|
||||
// 머리 행 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈 · 줄 행은 그 줄 그대로)
|
||||
left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Side.ts
|
||||
* 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시 · 785-4) —
|
||||
* · [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) = 바로 엑셀 고르기 창 → 올림 → 본 칸 불러오기 보기
|
||||
* · [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) = 바로 파일 하나 고르기 창(엑셀 · 경쟁사 .estx · .STC · 814-1) → 올림 → 본 칸 불러오기 보기
|
||||
* (`M02_MasterTemplete_Hopyo_Import` · 엑셀 + 오른쪽 호표 목록 · 가운데 상세 페이지 없음 · 누름 하나)
|
||||
* · 결과 층 고름 = 작은 고름(쓸 수 있는 층 둘 이상일 때만 · 일반 = 개인 하나라 안 보임)
|
||||
* · 「작업 중」 줄 = 층마다 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기
|
||||
* · 다른 엑셀 [불러오기] · 다른 화면으로 = 지금 보기 떠남(올린 파일 지움 · 743-1) — 고르고 안 만든 확인 캐시가 있으면 물음 한 번
|
||||
* 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger` · 층 = 구조물 조합 층).
|
||||
* 옛 [별칭 장부](338-3)는 걷음(815-2) — 별칭은 M01 요소 「별칭」 칸 · M01 별칭 모아 보기.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
@@ -16,12 +16,10 @@ import { createSegment } from "@ui/ui_template_segment";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
listHopyo,
|
||||
pageScope,
|
||||
uploadHopyo,
|
||||
type HopyoFile,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
|
||||
import {
|
||||
buildHopyoImport,
|
||||
LAYERS,
|
||||
@@ -32,8 +30,6 @@ import {
|
||||
import type { ManageLayer } from "./M02_MasterTemplete_Layer";
|
||||
|
||||
export interface HopyoSectionOptions {
|
||||
/** 이 층 쓰기 권한 — 별칭 장부 고침(구조물 조합 층이 제자리에서 바뀌어 열 때 읽음 · 720-4) */
|
||||
canWrite: () => boolean;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 메인 칸에 화면을 띄움 */
|
||||
@@ -67,25 +63,14 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
});
|
||||
start.classList.add("m02-layers__btn");
|
||||
start.title =
|
||||
"다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내거나 떠나면 올린 파일 지움)";
|
||||
"파일 하나 — 경쟁사 엑셀(.xls · .xlsx) 또는 경쟁사 파일(.estx · .STC) → 우리 정보로 다시 만듦(한시 · 끝내거나 떠나면 올린 파일 지움)";
|
||||
start.dataset.hopyoWizardOpen = "";
|
||||
const seg = el("div", { className: "m02-side__row" });
|
||||
seg.hidden = true;
|
||||
const jobs = el("div", { className: "m02-hopyo-side", attrs: { "data-hopyo-jobs": "" } });
|
||||
const ledger = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: LEDGER_TITLE,
|
||||
attrs: { type: "button", title: LEDGER_HINT, "data-hopyo-ledger-open": "" },
|
||||
});
|
||||
ledger.addEventListener("click", () => void showLedger());
|
||||
const body = el("div", {
|
||||
className: "m02-hopyo-side",
|
||||
children: [
|
||||
el("div", { className: "m02-side__row", children: [start, input] }),
|
||||
seg,
|
||||
jobs,
|
||||
ledger,
|
||||
],
|
||||
children: [el("div", { className: "m02-side__row", children: [start, input] }), seg, jobs],
|
||||
});
|
||||
let open: { key: string; view: HTMLElement; imp?: HopyoImport } | null = null;
|
||||
|
||||
@@ -145,14 +130,10 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
if (!file) return;
|
||||
start.disabled = true;
|
||||
uploadHopyo(scopeOf(pick), file)
|
||||
.then((got: HopyoFile & { 이어서?: boolean; 원파일?: boolean; 붙음?: string[] | null }) => {
|
||||
.then((got: HopyoFile & { 이어서?: boolean; 원파일?: boolean }) => {
|
||||
const tool = /\.estx$/i.test(file.name) ? "EST" : "STmate"; // 810-3 원파일 올림 안내
|
||||
if (got.원파일)
|
||||
showToast(
|
||||
`원파일 · ${tool} — 호표 ${got.호표수}${got.붙음?.length ? ` · 같은 공사 엑셀 ${got.붙음.join(", ")} 에 붙음` : ""}`,
|
||||
"success",
|
||||
);
|
||||
else if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info");
|
||||
if (got.이어서) showToast(`같은 파일 작업 중 — ${got.키} 이어서`, "info");
|
||||
else if (got.원파일) showToast(`${tool} 파일 — 단가산출표 · 산출근거`, "success");
|
||||
else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success");
|
||||
if (got.읽기불가) showToast(`가져온 식으로 열 수 없음 — ${got.읽기불가}`, "warning");
|
||||
return show(pick, got, true);
|
||||
@@ -181,7 +162,6 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
file,
|
||||
onEnded: () => {
|
||||
open = null;
|
||||
ledger.classList.remove("is-active");
|
||||
opt.show(title, el("div", { className: "m02-hopyo" })); // 끝냄 — 빈 본 칸
|
||||
void refresh();
|
||||
},
|
||||
@@ -189,19 +169,6 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
opt.show(`${title} — ${file.파일}`, imp.root);
|
||||
opt.onOpen();
|
||||
open = { key: file.키, view: imp.root, imp };
|
||||
ledger.classList.remove("is-active");
|
||||
void refresh();
|
||||
}
|
||||
|
||||
async function showLedger(): Promise<void> {
|
||||
if (open?.key === LEDGER_TITLE && open.view.isConnected) return;
|
||||
if (!(await leaveOk())) return;
|
||||
open?.imp?.leave();
|
||||
const view = buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() });
|
||||
opt.show(`${title} — ${LEDGER_TITLE}`, view);
|
||||
opt.onOpen();
|
||||
open = { key: LEDGER_TITLE, view };
|
||||
ledger.classList.add("is-active");
|
||||
void refresh();
|
||||
}
|
||||
|
||||
@@ -209,7 +176,6 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
if (open && !open.view.isConnected) {
|
||||
open.imp?.leave(); // 다른 화면으로 = 불러오기 떠남
|
||||
open = null;
|
||||
ledger.classList.remove("is-active");
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_http_cache import cached_file_response
|
||||
from M01_MasterData import M01_MasterData_Parts as parts
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
@@ -40,7 +41,7 @@ def _read(fn, *args):
|
||||
|
||||
@router.get("/logic-lib/logics", dependencies=[Depends(verify_session)])
|
||||
def get_logics() -> dict:
|
||||
return {"logics": _read(m01.logics, "", "", "", None)}
|
||||
return {"logics": parts.strip(_read(m01.logics, "", "", "", None))} # 부품 문서 뺌(813-2)
|
||||
|
||||
|
||||
@router.get("/logic-lib/subs", dependencies=[Depends(verify_session)])
|
||||
|
||||
@@ -69,7 +69,6 @@ export interface SangeunDetailOptions {
|
||||
/** 수식 탭 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음 · 802-4b-2) */
|
||||
onVersion?: (version: string) => void;
|
||||
slot?: HTMLElement; // 페이지 머리 줄 빈 자리(804-8) — 보기 고르기를 M01 처럼 머리 줄에
|
||||
match?: Parameters<typeof createSangeunSide>[0]; // 원문 패널 판정 띠 단추(810-4)
|
||||
}
|
||||
|
||||
const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" };
|
||||
@@ -104,7 +103,7 @@ export function mountSangeunDetail(
|
||||
const inputsOf = new Map<string, LogicInput[]>();
|
||||
const infoOf = new Map<string, SourceTab>(); // 더한줄 로직 → 원문 탭(752-1)
|
||||
let extraKeys: string[] = [];
|
||||
const side = opt.source ? createSangeunSide(opt.match) : null;
|
||||
const side = opt.source ? createSangeunSide() : null;
|
||||
let seq = 0;
|
||||
let alive = true;
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_MatchBand.ts
|
||||
* 가져온 식 ↔ 우리 기초 판정 띠(PLAN 810-4 · 요구 707 · 709 · 703) — 원문 패널 「어디서 왔나」 카드 맨 위.
|
||||
* 글 · 단추 = `logic/M02_Sangeun_Match.ts` `matchBand` · 길 POST `/m02/layers/{층}/sangeun/{키}/match`
|
||||
* {할일 찾기 | 바꾸기 | 되돌리기} — 서버가 다시 찾아 「확정」 일 때만 바꿈(화면 판정을 믿지 않음) ·
|
||||
* 바꾸기 · 되돌리기 = 확인창 · 저장 안 한 고침이 있으면 막음(먼저 [저장]) · 끝나면 `done(판)` = 그 SK 를 다시 엶
|
||||
* 층 쓰기 권한 없음(읽기만) = 단추 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { showToast } from "@ui/ui_template_toast";
|
||||
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { matchBand, type MatchAction } from "./logic/M02_Sangeun_Match";
|
||||
|
||||
export interface MatchCtx {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
key: string;
|
||||
readOnly: boolean;
|
||||
/** 저장 안 한 고침 있음 */
|
||||
dirty: () => boolean;
|
||||
/** 서버가 SK 를 고친 뒤(새 판) — 그 SK 다시 엶 */
|
||||
done: (version: string) => void;
|
||||
}
|
||||
|
||||
const LABEL: Record<Exclude<MatchAction, null>, string> = {
|
||||
찾기: "우리 기초와 연결 찾기",
|
||||
바꾸기: "우리 기초 사본으로 바꾸기",
|
||||
되돌리기: "가져온 식으로 되돌리기",
|
||||
};
|
||||
const ASK: Partial<Record<Exclude<MatchAction, null>, string>> = {
|
||||
바꾸기:
|
||||
"이 단가산출을 우리 기초 사본(찾은 옵션)으로 바꿈 — 키 그대로 · 일위대가 금액이 다시 셈됨 · 원래 가져온 식은 보관",
|
||||
되돌리기: "우리 기초 사본을 원래 가져온 식으로 되돌림 — 가져온 식 금액 그대로",
|
||||
};
|
||||
|
||||
/** 판정 띠 — 가져온 식 · 바꾼 SK 가 아니면 null */
|
||||
export function createMatchBand(sk: unknown, ctx: MatchCtx | undefined): HTMLElement | null {
|
||||
const band = matchBand(sk as Parameters<typeof matchBand>[0]);
|
||||
if (!band) return null;
|
||||
const act = band.action;
|
||||
const actions =
|
||||
act && ctx && !ctx.readOnly && ctx.key
|
||||
? [
|
||||
createButton({
|
||||
label: LABEL[act],
|
||||
variant: act === "바꾸기" ? "filled" : "ghost",
|
||||
size: 24,
|
||||
onClick: (ev) => void run(act, ctx, ev.currentTarget as HTMLButtonElement),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const details = band.details.length
|
||||
? el("div", { children: band.details.map((x) => createText(x, "muted", 12)) })
|
||||
: undefined;
|
||||
const root = createBanner({ tone: band.tone, text: band.text, details, actions });
|
||||
root.setAttribute("data-sk-match", act ?? "");
|
||||
return root;
|
||||
}
|
||||
|
||||
async function run(act: Exclude<MatchAction, null>, ctx: MatchCtx, btn: HTMLButtonElement) {
|
||||
if (ctx.dirty()) return void showToast("저장 안 한 고침이 있음 — 먼저 [저장]", "warning");
|
||||
const ask = ASK[act];
|
||||
if (ask && !(await askConfirm({ message: ask, okLabel: LABEL[act] }))) return;
|
||||
const label = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = act === "찾기" ? "찾는 중…" : "바꾸는 중…";
|
||||
try {
|
||||
const owner = ctx.owner ? `?owner=${encodeURIComponent(ctx.owner)}` : "";
|
||||
const path = `/layers/${ctx.layer}/sangeun/${encodeURIComponent(ctx.key)}/match${owner}`;
|
||||
const got = await call<{ 판?: string }>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 할일: act }),
|
||||
});
|
||||
ctx.done(got.판 ?? "");
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : String(e), "error");
|
||||
btn.disabled = false;
|
||||
btn.textContent = label;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* 맨 위 「단가산출 기초 찾기」(807-1 · M01 원문 패널과 같은 `LogicLab_SourceFind`) — 고르면 그 기초 원문(보기만 · 기억 안 함) ·
|
||||
* 지우면 처음 화면 · 처음 화면 = 우리 SK 는 기초 원문 · 가져온 식 SK 는 「어디서 왔나」 카드
|
||||
* (프로그램 · 파일 · 블록 · 행 · 가져온 때 · 표지 요약 + 엑셀 글의 품셈 절 글만 — 찾기 · 잇기 없음)
|
||||
* 카드 맨 위 = 우리 기초 판정 띠(810-4 · `Sangeun_MatchBand`) · 우리 기초 사본으로 바꾼 SK = 보관한 가져온 식 카드
|
||||
* 옛 우리 기초 판정 띠(810-4)는 걷음(815-2) · 옛 바꾼 SK(`가져온보관`) = 보관한 가져온 식 카드(읽기만)
|
||||
* 일위대가 줄 모달 안 상세는 안 만듦(부르는 쪽이 `source` 를 안 켬).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -23,7 +23,6 @@ import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_S
|
||||
import { createSourceFind } from "../M01_MasterData/M01_MasterData_UI_LogicLab_SourceFind";
|
||||
import { originOf, type Origin, type OriginMeta } from "./logic/M02_Sangeun_Origin";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { createMatchBand, type MatchCtx } from "./M02_MasterTemplete_Sangeun_MatchBand";
|
||||
|
||||
export interface SourceTab {
|
||||
label: string;
|
||||
@@ -63,7 +62,7 @@ const tx = (k: keyof typeof TEXT, n = 0): string =>
|
||||
TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n));
|
||||
|
||||
/** 「어디서 왔나」 카드 — 표지는 셋까지(더 = 단추로 펼침) */
|
||||
function originCard(o: Origin, band: HTMLElement | null): HTMLElement {
|
||||
function originCard(o: Origin): HTMLElement {
|
||||
const marks = el("div");
|
||||
const showMarks = (all: boolean): void =>
|
||||
marks.replaceChildren(
|
||||
@@ -82,7 +81,6 @@ function originCard(o: Origin, band: HTMLElement | null): HTMLElement {
|
||||
title: tx("Origin"),
|
||||
pad: 12,
|
||||
body: [
|
||||
...(band ? [band] : []),
|
||||
...(o.요약 ? [el("strong", { text: o.요약, attrs: { "data-origin-native": "" } })] : []),
|
||||
createKv([
|
||||
{ label: tx("Program"), value: o.프로그램 },
|
||||
@@ -91,7 +89,7 @@ function originCard(o: Origin, band: HTMLElement | null): HTMLElement {
|
||||
{ label: tx("Rows"), value: o.행 },
|
||||
{ label: tx("When"), value: o.때 },
|
||||
{ label: tx("Marks"), value: tx("MarksN", o.표지.length) },
|
||||
...(o.확정 ? [{ label: tx("Sure"), value: o.확정 }] : []),
|
||||
...(o.확정 ? [{ label: o.확정이름 ?? tx("Sure"), value: o.확정 }] : []),
|
||||
]),
|
||||
marks,
|
||||
el("strong", { text: tx("Clauses") }),
|
||||
@@ -103,9 +101,9 @@ function originCard(o: Origin, band: HTMLElement | null): HTMLElement {
|
||||
return card;
|
||||
}
|
||||
|
||||
export function createSangeunSide(match?: MatchCtx): {
|
||||
export function createSangeunSide(): {
|
||||
wrap: (main: HTMLElement) => HTMLElement;
|
||||
/** 원문 탭 · SK 문서(`가져온` · 바꾼 SK `가져온보관` — 있으면 첫 화면 = 카드 + 판정 띠) */
|
||||
/** 원문 탭 · SK 문서(`가져온` · 옛 바꾼 SK `가져온보관` — 있으면 첫 화면 = 카드) */
|
||||
set: (tabs: SourceTab[], sk?: unknown) => void;
|
||||
} {
|
||||
const view = createSourceView({
|
||||
@@ -140,8 +138,7 @@ export function createSangeunSide(match?: MatchCtx): {
|
||||
view.show(found);
|
||||
return void content.replaceChildren(view.root);
|
||||
}
|
||||
if (origin)
|
||||
return void content.replaceChildren(originCard(origin, createMatchBand(skDoc, match)));
|
||||
if (origin) return void content.replaceChildren(originCard(origin));
|
||||
if (!tabs.length)
|
||||
return void content.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted"));
|
||||
at = Math.min(at, tabs.length - 1);
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
* 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄).
|
||||
* 717-2b = 상세 · 고침(`Sangeun_Detail.ts` mountSangeunDetail) · [저장]은 그 손잡이 `getDoc` · 고침 알림 `opt.onChanged`.
|
||||
* 804-8 = 페이지 [저장] 한 번에 수식도(`flush` 먼저 · 새 판) · 보기 고르기는 머리 줄 `slot`.
|
||||
* 810-4 = 원문 패널 판정 띠 [바꾸기] · [되돌리기] · [연결 찾기] 뒤 그 SK 를 다시 읽어 상세를 다시 엶(새 판).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readTemplate } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
@@ -25,37 +23,9 @@ export function mountSangeunView(
|
||||
doc: unknown,
|
||||
opt: SangeunViewOptions,
|
||||
): { getDoc: () => Promise<unknown>; destroy: () => void } {
|
||||
let dirty = false;
|
||||
const onChanged = (d: boolean): void => ((dirty = d), opt.onChanged(d));
|
||||
/** 판정 띠 단추(810-4) — 서버가 SK 를 고친 뒤 새 판 · 그 SK 를 다시 읽어 상세를 다시 엶 */
|
||||
const match = {
|
||||
layer: opt.layer,
|
||||
owner: opt.owner ?? null,
|
||||
key: opt.name,
|
||||
readOnly: opt.readOnly,
|
||||
dirty: () => dirty,
|
||||
done: (version: string): void => {
|
||||
opt.onVersion?.(version);
|
||||
void readTemplate(opt.layer, "sangeun", opt.name, null, opt.owner ?? null).then((got) => {
|
||||
if (!alive) return;
|
||||
one.destroy();
|
||||
one = mount(got.문서);
|
||||
onChanged(false);
|
||||
});
|
||||
},
|
||||
};
|
||||
const mount = (d: unknown) =>
|
||||
mountSangeunDetail(host, d, {
|
||||
...opt,
|
||||
onChanged,
|
||||
owner: opt.owner ?? null,
|
||||
source: true,
|
||||
match,
|
||||
}); // 752-1
|
||||
let alive = true;
|
||||
let one = mount(doc);
|
||||
const one = mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1
|
||||
return {
|
||||
getDoc: () => one.flush().then(() => one.getDoc()),
|
||||
destroy: () => ((alive = false), one.destroy()),
|
||||
destroy: () => one.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ const STUB_DRAWING = {
|
||||
export interface SideOptions {
|
||||
/** 시스템 관리자 — 도면 양식(시스템 층에만 있음)을 늘 보임(720-4) */
|
||||
isSystem: boolean;
|
||||
/** 지금 구조물 조합 층을 고칠 수 있나(층이 제자리에서 바뀜) — 일위대가 불러오기 장부 */
|
||||
/** 지금 구조물 조합 층을 고칠 수 있나(층이 제자리에서 바뀜) */
|
||||
canWrite: () => boolean;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
@@ -164,7 +164,6 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
const drawing = section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"));
|
||||
// 일위대가 불러오기(324-4 · 제목은 718-1) · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
|
||||
const hopyo = buildHopyoSection({
|
||||
canWrite: opt.canWrite,
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: paint,
|
||||
|
||||
@@ -12,11 +12,8 @@ from __future__ import annotations
|
||||
import io
|
||||
import re
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as read_est
|
||||
|
||||
STM_SHEETS = (
|
||||
"단가산출근거",
|
||||
"단가산출근거목록표",
|
||||
@@ -233,23 +230,3 @@ def est_prices(book: dict[str, list]) -> tuple[list[dict[str, Any]], dict[int, d
|
||||
one = {"종류": "산근", "이름": f"단산{n}", "규격": ""}
|
||||
out.append({**one, "비목": k, "값": san[n][k]})
|
||||
return out, san
|
||||
|
||||
|
||||
def est_blocks(book: dict[str, list]) -> list[dict[str, Any]]:
|
||||
"""→ [{번, 이름, 단위, 글 [줄], 글행 [행]}]."""
|
||||
blocks = read_est.read(book.get(read_est.SHEET))
|
||||
keep = ("번", "이름", "단위", "글", "글행")
|
||||
return [{k: b[k] for k in keep} for b in blocks.values()]
|
||||
|
||||
|
||||
def load(path: str | Path) -> dict[str, Any]:
|
||||
"""파일 → {꼴, 블록, 단가(STmate dict | EST 목록), 총괄(EST)}."""
|
||||
path = Path(path)
|
||||
book = sheets(path.name, path.read_bytes())
|
||||
kind = kind_of(book)
|
||||
if kind == "STmate":
|
||||
return {"꼴": kind, "블록": stmate_blocks(book), "단가": stmate_prices(book)}
|
||||
if kind == "EST":
|
||||
prices, san = est_prices(book)
|
||||
return {"꼴": kind, "블록": est_blocks(book), "단가": prices, "총괄": san}
|
||||
raise ValueError("STmate · EST 산근 시트 없음")
|
||||
|
||||
+145
-126
@@ -1,6 +1,6 @@
|
||||
"""경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(PLAN 793-2a · 요구서_652 · eval 없음).
|
||||
|
||||
793-1 줄 나무(`M02_San_Stmate` · `M02_San_Est`) → `{로직, 원본}` — 꼴 = 793-2 가져온 식 SK
|
||||
793-1 줄 나무(`M02_San_Stmate`) → `{로직, 원본}`(EST = 원파일 · 엑셀 둘 다 `M02_Doc_NativeEst` · 812-1) — 꼴 = 793-2 가져온 식 SK
|
||||
만들기 몸(`M02_Sangeun_Imported.py` 머리) · 로직 = `_틀.md` 7 장 꼴(셈 = `master_formula.run` →
|
||||
`master_cut.engine` · `요소` 는 SK 서버가 채움).
|
||||
- 로직 = {이름, 결과단위, 입력 [], 중간 [{이름, 식, 표시?}], 호표,
|
||||
@@ -28,12 +28,13 @@ from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
from M02_MasterTemplete import M02_San_Expr as ex
|
||||
from M02_MasterTemplete.M02_San_Link import combo_link, hourly_text # noqa: F401 — 밖에서 em. 으로 부름
|
||||
from M02_MasterTemplete.M02_San_Link import m01 as _m01
|
||||
|
||||
COMP = {"L": "노무비", "M": "재료비", "S": "경비"}
|
||||
_CODE = {v: k for k, v in COMP.items()}
|
||||
_SUM = "@합{}@" # 누계 % 밑 자리(블록 끝에 `finish` 가 채움)
|
||||
KIND = {"L": "인력", "M": "재료", "X": "기계", "D": "단가산출", "B": "단가산출"}
|
||||
KIND_EST = {"노임": "인력", "자재": "재료", "기계": "기계", "견적": "고정", "산근": "단가산출"}
|
||||
RESERVED = {"노무비", "재료비", "경비", "계", "번지", "단계", "줄", "찾기", "로직", "그리고"}
|
||||
RESERVED |= {"또는", "만약", "올림", "버림", "반올림", "최소", "최대"}
|
||||
_KEY = re.compile(r"[A-Z]{2}\d{6}")
|
||||
@@ -176,40 +177,54 @@ def reprice(logic: dict, where: dict, price: dict[str, float]) -> int:
|
||||
return n
|
||||
|
||||
|
||||
def _m01(kind: str, src: dict) -> tuple[str | None, str | None]:
|
||||
"""1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 ·
|
||||
환율만 — 재료 · 인력 · 기계는 지금처럼 조용히 이음 없음). 재료 줄 이름이 M01 유가전력(MO)이면
|
||||
유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님)."""
|
||||
if kind not in ("인력", "재료", "기계"):
|
||||
return None, None
|
||||
from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||||
from M02_MasterTemplete import M02_Hopyo_Row as hrow
|
||||
# ── 기계 부분(812-9 · 조합 기계 = 줄 둘) ─────────────────────────────────
|
||||
def machine_parts(src: dict) -> list[dict]:
|
||||
"""기계 자원 → 부분 찾기(`M02_Machine_Find.find_all` · 조합 = 둘 이상) · 기계 아님 = []."""
|
||||
if src.get("갈래") != "기계":
|
||||
return []
|
||||
from M02_MasterTemplete import M02_Machine_Find as mfind
|
||||
|
||||
line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")}
|
||||
if kind == "재료":
|
||||
if elem.oil_rows(line["이름"]):
|
||||
kind = "유가"
|
||||
elif line["이름"].strip().upper() in {x.upper() for pair in hrow.CURRENCY for x in pair}:
|
||||
kind = "환율"
|
||||
if kind == "유가": # MO 전부 → 줄 규격이 상세구분(전국 · 지역)이면 그것만
|
||||
got = [
|
||||
{"키": r["키"], "규격": str(r.get("상세구분") or "")}
|
||||
for r in elem.oil_rows(line["이름"])
|
||||
]
|
||||
spec = line["규격"].replace(" ", "")
|
||||
got = (
|
||||
[x for x in got if x["규격"] == spec]
|
||||
or [x for x in got if spec and spec in x["규격"]]
|
||||
or got
|
||||
)
|
||||
return mfind.find_all(src.get("이름"), src.get("규격"))
|
||||
|
||||
|
||||
def first_spec(one: dict, parts: list[dict]) -> dict:
|
||||
"""조합 기계 첫 줄 규격 = 그 기계 규격(칩 글 · 「0.7+브레이카」 아님 · 814-1) — 자원 원문은 그대로."""
|
||||
if len(parts) > 1 and parts[0].get("규격") is not None:
|
||||
one["규격"] = parts[0]["규격"]
|
||||
return one
|
||||
|
||||
|
||||
def part_line(main: dict, part: dict, names: "Names") -> dict:
|
||||
"""조합 기계 다음 줄 — 같은 수량 식 · 값 0(파일 경비 = 한 덩이 = 첫 줄) · 제 이음."""
|
||||
res = {**main["자원"], "이름": part["이름"], "규격": part["규격"], "찾기": part, "조합": True}
|
||||
res.pop("조합첫", None)
|
||||
res["단가"] = dict.fromkeys(res.get("단가") or COMP, 0)
|
||||
title = part["후보"][0]["이름"] if part["키"] else part["이름"]
|
||||
one = {"이름": names.item(str(title)), "규격": part["규격"], "단위": main.get("단위") or ""}
|
||||
one |= {"수량": main["수량"], "자원": res}
|
||||
if main.get("종류") == "로직": # 우리 조합 식(814-1 `조합식`)에 든 다음 줄 = 이음 없음 · 값 0
|
||||
link = None if res.get("조합식") else hourly_text(part["키"])
|
||||
one |= {"종류": "로직", "값묶음": bundle(res), "이음": link}
|
||||
else:
|
||||
got = hrow.m01_picks(kind, line)
|
||||
keys = {x.get("키") for x in got if x.get("키")}
|
||||
if len(keys) == 1:
|
||||
return keys.pop(), None
|
||||
if len(keys) > 1 and kind in ("유가", "환율"):
|
||||
return None, f"{NOTE}{kind} 후보 여럿({len(keys)}) · 사람 확인"
|
||||
return None, None
|
||||
one |= {"종류": main["종류"], "비목": main["비목"], "단가": Decimal(0), "이음": part["키"]}
|
||||
return one | ({"자리": main["자리"]} if main.get("자리") else {})
|
||||
|
||||
|
||||
def part_marks(src: dict, parts: list[dict]) -> list[str]:
|
||||
"""부분 표지 — 못 이은 부분(후보 여럿 · 못 찾음) · 조합 기계(경비 한 덩이 = 첫 줄 · 다음 줄 0)."""
|
||||
from M02_MasterTemplete import M02_Machine_Find as mfind
|
||||
|
||||
out = [m for p in parts if (m := mfind.mark(p, p["이름"], p["규격"]))]
|
||||
if len(parts) > 1:
|
||||
said = " ".join(x for x in (str(src.get("이름") or ""), str(src.get("규격") or "")) if x)
|
||||
names = " + ".join(
|
||||
f"{p['후보'][0]['이름']} {p['후보'][0]['규격']}" if p["키"] else p["이름"]
|
||||
for p in parts
|
||||
)
|
||||
out.append(
|
||||
f"{NOTE}조합 기계 — 경비는 한 덩이 「{said}」 = {names} · 엑셀 값은 첫 줄 · 다음 줄 0"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ── 내보낼 로직 틀 ─────────────────────────────────────────────────────
|
||||
@@ -291,6 +306,7 @@ class _Stm:
|
||||
self.open: list[tuple[int, list[str]]] = [] # 위 단계가 아직 안 담은 단계(엔진 `_cover`)
|
||||
self.out: dict[str, int] = {} # 제외 단계 줄 → 그 계 줄까지 줄 수(합에서 빠지는 때)
|
||||
self.fix: list[tuple[dict, int]] = [] # 누계 % 덧줄 · 그때 줄 수
|
||||
self.noted: set[tuple] = set() # 기계 표지 단 자원(812-9 · 블록에 한 번)
|
||||
|
||||
def brackets(self, node: tuple, src: dict, kept: dict) -> None:
|
||||
"""끼움 `[Q=0.208]` → 중간(왼쪽부터 · 원문 차례)."""
|
||||
@@ -378,6 +394,8 @@ class _Stm:
|
||||
unt = (pr.get("meta") or {}).get("UNT")
|
||||
one_, to = node["자원"]["비목"], node["자원"]["옮김"]
|
||||
name = _label(node) or str(pr.get("이름") or code)
|
||||
if node.get(_MERGED): # 814-1 — 기계 세 줄(노 · 재 · 경) = 기계 한 줄 · 이름 = 기계
|
||||
name = str(pr.get("이름") or code)
|
||||
if unt in ev.SPECIAL: # 특수 % — 처음부터 비목 누계 · 앞 계표시
|
||||
dst = to or {"L": "L", "M": "M", "S": "S"}.get(code[0])
|
||||
if unt == "T%":
|
||||
@@ -396,11 +414,26 @@ class _Stm:
|
||||
if not one_ and len(sole) == 1: # 비목 꼬리 없어도 값 든 비목 하나면 단가 줄(셈 같음)
|
||||
one_, to = sole[0], None
|
||||
res |= {"비목": one_, "옮김": to} if one_ else {}
|
||||
link, many = _m01(res["갈래"], res) if self.keys else (None, None)
|
||||
one = resource(o.names.item(name), pr.get("단위"), cf, res, link)
|
||||
parts = machine_parts(res) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
|
||||
if parts:
|
||||
res["찾기"], link, many = parts[0], parts[0]["키"], None
|
||||
res |= {"조합첫": True} if len(parts) > 1 else {}
|
||||
else:
|
||||
link, many = _m01(res["갈래"], res) if self.keys else (None, None)
|
||||
one = first_spec(resource(o.names.item(name), pr.get("단위"), cf, res, link), parts)
|
||||
if parts and one["종류"] == "로직":
|
||||
one["이음"] = combo_link(one, parts) or hourly_text(link)
|
||||
self.line(one, node, True, src)
|
||||
if many:
|
||||
o.glyph(src["행"], src["글"], mark=many)
|
||||
for at, glyph in node.get(_MERGED) or []: # 묶은 다른 두 줄 원문 = 같은 자리
|
||||
o.glyph(at, glyph, one["이름"], self.rows.get(at, {}).get("C"))
|
||||
bare = {k: v for k, v in node.items() if k not in ("번지", _MERGED)}
|
||||
for p in parts[1:]:
|
||||
self.line(part_line(one, p, o.names), bare, True, src)
|
||||
seen = (res.get("이름"), res.get("규격"))
|
||||
marks = [] if seen in self.noted else part_marks(res, parts)
|
||||
self.noted.add(seen)
|
||||
for m in [*marks, *([many] if many else [])]:
|
||||
o.glyph(src["행"], src["글"], mark=m)
|
||||
|
||||
def rate(self, node: dict, src: dict, cf: str) -> None:
|
||||
rate, o = node["율"], self.o
|
||||
@@ -478,11 +511,84 @@ def _ratios(meta: dict) -> str:
|
||||
return " · ".join(f"{n} {v}" for n, v in pairs if v is not None and abs(_num(v) - 1) > 1e-9)
|
||||
|
||||
|
||||
_MERGED = "_묶음줄" # 세 줄을 묶은 줄 나무 — 나머지 두 줄 [(행, 글)]
|
||||
_BAD = ("셈 못 함", "엔진 꼴 없음")
|
||||
|
||||
|
||||
def triples(nodes: list[dict]) -> list[list[int]]:
|
||||
"""814-1 — 같은 기계(X 코드)의 노 · 재 · 경 세 참조 줄(같은 수량 식 · 번지 · 빼기 · 옮김 없음) 자리
|
||||
[[i, j, k], …] — 사이 빈 줄은 건너뜀."""
|
||||
out, k = [], 0
|
||||
while k < len(nodes):
|
||||
at, j = [k], k + 1
|
||||
while len(at) < 3 and j < len(nodes):
|
||||
if nodes[j].get("종류") == "빈" and not str(nodes[j].get("글") or "").strip():
|
||||
j += 1
|
||||
continue
|
||||
at.append(j)
|
||||
j += 1
|
||||
if len(at) == 3 and _same_machine([nodes[i] for i in at]):
|
||||
out.append(at)
|
||||
k = j
|
||||
else:
|
||||
k += 1
|
||||
return out
|
||||
|
||||
|
||||
def merge(nodes: list[dict], groups: list[list[int]]) -> list[dict]:
|
||||
"""세 줄 자리 → 비목 없는 한 줄(값묶음 · 조합이면 그 뒤 기계마다 줄) · 나머지 두 줄 원문은 같은 자리."""
|
||||
first = {g[0]: g for g in groups}
|
||||
drop = {i for g in groups for i in g[1:]}
|
||||
out = []
|
||||
for i, node in enumerate(nodes):
|
||||
if i in first:
|
||||
res = {**node["자원"], "비목": None, "옮김": None}
|
||||
rest = [(nodes[j].get("행"), nodes[j].get("글") or "") for j in first[i][1:]]
|
||||
out.append({**node, "자원": res, _MERGED: rest})
|
||||
elif i not in drop:
|
||||
out.append(node)
|
||||
return out
|
||||
|
||||
|
||||
def _same_machine(three: list[dict]) -> bool:
|
||||
if any(x.get("종류") != "참조" or x.get("오류") or not x.get("자원") for x in three):
|
||||
return False
|
||||
res = [x["자원"] for x in three]
|
||||
if not str(res[0].get("코드") or "").startswith("X") or len({r["코드"] for r in res}) != 1:
|
||||
return False
|
||||
if sorted(str(r.get("비목")) for r in res) != ["L", "M", "S"] or any(
|
||||
r.get("옮김") for r in res
|
||||
):
|
||||
return False
|
||||
return all(len({repr(x.get(k)) for x in three}) == 1 for k in ("식", "번지", "빼기"))
|
||||
|
||||
|
||||
def stmate(
|
||||
block: dict, nodes: list[dict], prices: dict, rule: str, keys: bool = False, file: str = ""
|
||||
) -> dict:
|
||||
"""STmate 블록(`M02_San_Book.stmate_blocks` 한 개) + 줄 나무 + 같은 책 단가 → {로직, 원본}.
|
||||
rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키(이음) 찾기."""
|
||||
rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키(이음) 찾기 ·
|
||||
기계 세 줄 = 한 줄(`triples` · 814-1) — 묶어서 셈 못 하는 줄이 생기면 묶지 않은 꼴."""
|
||||
groups = triples(nodes)
|
||||
out = _stmate(block, merge(nodes, groups), prices, rule, keys, file)
|
||||
bad = _bad(out)
|
||||
if not groups or not bad:
|
||||
return out
|
||||
took: list[
|
||||
list[int]
|
||||
] = [] # 묶어서 셈 못 하는 세 줄만 그대로(율 범위 C · 누계 % 가 비목 하나를 부름)
|
||||
base = _bad(_stmate(block, nodes, prices, rule, keys, file))
|
||||
for g in groups:
|
||||
if _bad(_stmate(block, merge(nodes, [*took, g]), prices, rule, keys, file)) <= base:
|
||||
took.append(g)
|
||||
return _stmate(block, merge(nodes, took), prices, rule, keys, file)
|
||||
|
||||
|
||||
def _bad(out: dict) -> int:
|
||||
return sum(1 for m in out["원본"]["표지"] if any(b in m for b in _BAD))
|
||||
|
||||
|
||||
def _stmate(block: dict, nodes: list[dict], prices: dict, rule: str, keys: bool, file: str) -> dict:
|
||||
me = prices.get(block["코드"]) or {}
|
||||
meta = me.get("meta") or {}
|
||||
o = Out(block.get("이름"), me.get("단위"), "STmate", block["코드"])
|
||||
@@ -531,90 +637,3 @@ def stmate(
|
||||
more["계약"] = {"낙찰률": float(meta["NAG"]), "금액": _cols(block["머리"])}
|
||||
end = "큰나머지" if rule.startswith("합버림") else "비목버림"
|
||||
return o.done(end, adjust, _cols(kye), "단가산출근거", file, **more)
|
||||
|
||||
|
||||
# ── EST ────────────────────────────────────────────────────────────────
|
||||
_VAR = re.compile(r"([A-Za-z][A-Za-z0-9_]*)\s*(?:\([^)]*\))?\s*=")
|
||||
|
||||
|
||||
def _est_res(role: dict, value: float, comp: str | None) -> dict:
|
||||
"""EST 단가 토큰 정체 → 자원 이음 열쇠(옮김 = 성분 비목이 단가 비목과 다를 때)."""
|
||||
p = role["정체"]
|
||||
pc = p["비목"] if p["비목"] in COMP else (comp or "S")
|
||||
res = {"꼴": "EST", "갈래": KIND_EST.get(p["종류"], "고정"), "종류": p["종류"]}
|
||||
res |= {"이름": p["이름"], "규격": p["규격"], "단위": None}
|
||||
res |= {"단가": {pc: value}}
|
||||
res |= {"비목": pc, "옮김": comp if comp and comp != pc else None, "열쇠비목": p["비목"]}
|
||||
return res | ({"모호": True} if role.get("모호") else {})
|
||||
|
||||
|
||||
def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: str = "") -> dict:
|
||||
"""EST 블록(`M02_San_Book.est_blocks` 한 개) + 복원 줄(`M02_San_Est.parse_block`) +
|
||||
총괄표 성분 want {T, L, M, S} → {로직, 원본} · 성분을 낸 줄만 합(나머지 = 합밖)."""
|
||||
des = ev.designate(lines, want)
|
||||
mine = {k: c for c, k in des.items() if isinstance(k, int)}
|
||||
o = Out(block.get("이름"), block.get("단위"), "EST", block.get("번"))
|
||||
rows = block.get("글행") or []
|
||||
if want.get("칸"): # 당초 칸 사용 · 적용 ≠ 당초(797-1a)
|
||||
o.marks.append(f"{NOTE}{want['칸']}")
|
||||
for c, k in des.items():
|
||||
if k is None:
|
||||
o.marks.append(f"성분 {COMP[c]} {want[c]} 을 낸 줄 없음")
|
||||
val = ev.run_est(lines)
|
||||
ref: dict[int, str] = {} # 차례 → 엔진 부름 글(앞줄 · 숨은 합)
|
||||
for L in lines:
|
||||
k, form, kind = L["차례"], L["꼴"], L.get("종류")
|
||||
at, glyph = rows[k] if k < len(rows) else k, L["원글"]
|
||||
comp = mine.get(k)
|
||||
if form == "빈":
|
||||
continue
|
||||
if form == "변수" and L.get("값") is not None:
|
||||
m = _VAR.search(L["원글"])
|
||||
eng = o.names.line(m.group(1) if m else "변수")
|
||||
o.middle(eng, num(L["값"]), None, at, glyph) # 얼린 값(금액엔 보인 값이 박힘)
|
||||
continue
|
||||
if form not in ("돈", "값"):
|
||||
shown = L.get("보인") if form == "글끝" else L.get("값") if form == "식못읽음" else None
|
||||
if shown is None:
|
||||
o.glyph(at, glyph)
|
||||
else: # 금액 줄 못 읽음 — 보인 값 얼림(글줄 값으로 보존)
|
||||
o.glyph(at, glyph, value=shown, mark=f"{NOTE}금액 줄 못 읽음 · 보인 값 얼림")
|
||||
continue
|
||||
head = {} if comp else {"합밖": True}
|
||||
try:
|
||||
place = _place(L.get("규칙"))
|
||||
if k not in val:
|
||||
raise EmitError("보인 값이 되는 식 · 자리 못 찾음 — 보인 값으로 얼림")
|
||||
if kind == "단가줄":
|
||||
i, role = next((i, r) for i, r in L["역할"].items() if r["갈래"] == "단가")
|
||||
price = next(n[1] for n in ex.numbers(L["식"]) if n[2] == i)
|
||||
res = _est_res(role, price, comp)
|
||||
link, many = _m01(res["갈래"], res) if keys else (None, None)
|
||||
qty = text(L["식"], {}, {i: "1"})
|
||||
one = resource(o.names.item(L.get("라벨") or ""), None, qty, res, link)
|
||||
one |= {**head, **({"자리": place} if place else {})}
|
||||
o.add_line(one, True, at, glyph, L["값"])
|
||||
if many:
|
||||
o.glyph(at, glyph, mark=many)
|
||||
if role.get("모호"): # 임의 선택 아님 — 후보를 사람이 고름
|
||||
o.glyph(at, glyph, mark=f"{NOTE}단가 후보 여럿({role['남은']}) · 사람 확인")
|
||||
elif role.get("갈림") in ("문맥", "기계 세 줄"):
|
||||
o.glyph(at, glyph, mark=f"{NOTE}추정 — 단가 정체 근거 {role['갈림']}")
|
||||
ref[k] = f"줄.'{one['이름']}'"
|
||||
continue
|
||||
if kind == "값줄":
|
||||
parts = [ref.get(j) or num(lines[j]["값"]) for j in L["숨은"]["줄"]]
|
||||
formula = " + ".join(parts)
|
||||
else:
|
||||
swap = {i: ref[r["줄"]] for i, r in L["역할"].items() if r.get("줄") in ref}
|
||||
formula = text(L["식"], {}, swap)
|
||||
except EmitError as error:
|
||||
o.glyph(at, glyph, mark=str(error))
|
||||
formula, place = num(L["값"]), None
|
||||
name = o.names.line(L.get("라벨") or "줄")
|
||||
one = {"이름": name, "식": formula, "비목": COMP[comp or L.get("비목") or "S"]}
|
||||
o.add_line(one | head | ({"자리": place} if place else {}), False, at, glyph, L["값"])
|
||||
if k in val:
|
||||
ref[k] = name
|
||||
check = _money(want) | {"계": want["T"]}
|
||||
return o.done("비목버림", None, check, "단가산출", file)
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
"""EST 산근 보이는 글 → 식 복원(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-A` 3 장 · eval 없음).
|
||||
"""EST 식 글 토큰(PLAN 793-1 · 812-1 · eval 없음) — 원파일 산근 변환(`M02_Doc_NativeEst`)이 쓰는 수 · 토큰 읽기만.
|
||||
|
||||
엑셀엔 식이 없고 인쇄 글뿐 — 「라벨 : 식 = 결과」 로 나눠 식을 되살림. 셈은 `M02_San_Eval.run_est`.
|
||||
|
||||
줄 꼴(`꼴`) — 빈 · 그림(분수 ─) · 글 · 글끝(끝이 결과 아님) · 식못읽음 · 변수 · 값 · 돈.
|
||||
- 돈(`종류` 단가줄 · 참조줄 · 상수줄) — 라벨 · 비목(라벨 끝 노무비 · 재료비 · 경비) ·
|
||||
값(보인 결과) · 식(`M02_San_Expr` 나무) ·
|
||||
역할 {수 토큰 차례: 단가(후보 · 정체 · 모호) | 앞줄(같은 블록 앞 결과 줄 차례) | 상수} ·
|
||||
규칙(보인 결과가 되는 줄 자리 속성 · 기본 버림1)
|
||||
- 값(`종류` 값줄 「소 계 : 1,355.9」) — 숨은 = {갈래 복사 | 합, 줄 [차례], 규칙}(앞 결과 하나 또는
|
||||
바로 앞 돈 · 값 줄 이음 합)
|
||||
- 변수(`종류` 변수식 · 변수값 · 변수기호 · 변수글) — 값 · 규칙 = 얼림(금액엔 반올림된 값이 박힘)
|
||||
옛 엑셀 글 추정 사슬(줄 꼴 · 역할 · 값 역색인 · `parse_block`)은 812-1 엑셀 읽기기(`M02_San_Xlsx`)로 바뀌어 걷음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Expr as ex
|
||||
from M02_MasterTemplete.M02_San_Book import flat
|
||||
|
||||
COMPW = {"노무비": "L", "재료비": "M", "경비": "S"}
|
||||
NUMRE = r"\d{1,3}(?:,\d{3})+(?:\.\d*)?|\d+(?:\.\d*)?|\.\d+"
|
||||
EPS = ex.EPS
|
||||
OPS = {"×": "*", "x": "*", "X": "*", "*": "*", "÷": "/", "/": "/", "+": "+", "-": "-"}
|
||||
OPS |= {"-": "-", "−": "-"}
|
||||
_UNIT = re.compile(r"\s*(?:[A-Za-z가-힣㎡㎥㎏ℓ㎝㎜°˚’'\"Øø]+(?:\s*/\s*[A-Za-z가-힣㎡㎥㎏ℓ]+)?)")
|
||||
VARLINE = re.compile(r"^\s*(?:[○●◦·\-]\s*)?[A-Za-z][A-Za-z0-9_]*\s*(?:\([^)]*\))?\s*=")
|
||||
RES = re.compile(
|
||||
r"^\s*(?:[A-Z]\s+)?("
|
||||
+ NUMRE
|
||||
+ r")\s*\.?\s*(?:원)?\s*(?:[₩\\]?\s*/\s*\S+)?\s*(?:\([^()]*\))?\s*$"
|
||||
)
|
||||
_PLAIN = re.compile(r"[\s\d.,×÷+\-()A-Za-z가-힣㎡㎥]*")
|
||||
_LABEL = re.compile(r"^(?P<l>[^::=]{0,40}?)\s*[::]\s*(?P<r>.*)$")
|
||||
TAIL = re.compile(r"^\s*(" + NUMRE + r")\s*\.?\s*[::][^=]*$") # 결과 뒤 꼬리 글 「3,094.2 : 채집」
|
||||
_HEADING = re.compile(r"^\s*(\d+|[가-하])\s*[.)]")
|
||||
|
||||
|
||||
def num(s: str) -> float:
|
||||
@@ -112,214 +89,3 @@ def tokens(seg: str) -> list[tuple] | None:
|
||||
if depth != 0 or want or not any(t[0] in ("연산", "%") for t in out):
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
def calc(node: tuple, sub: dict | None = None) -> float | None:
|
||||
try:
|
||||
return ex.value(node, {"V": {}, "sub": sub})
|
||||
except ex.ExprError:
|
||||
return None
|
||||
|
||||
|
||||
def label_comp(label: str) -> str | None:
|
||||
w = flat(label)
|
||||
return next((v for k, v in COMPW.items() if w.endswith(k)), None)
|
||||
|
||||
|
||||
def parse_line(text: str) -> dict[str, Any]:
|
||||
"""보이는 글 한 줄 → 꼴 · 라벨 · 결과 · 식 나무(역할 전)."""
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return {"꼴": "빈"}
|
||||
if "─" in t or _PLAIN.fullmatch(t) and "=" not in t and ":" not in t:
|
||||
return {"꼴": "그림" if "─" in t else "글"}
|
||||
label, rhs = "", t
|
||||
mc = _LABEL.match(t)
|
||||
if mc and not re.search(r"\d\s*$", mc.group("l")):
|
||||
label, rhs = mc.group("l"), mc.group("r")
|
||||
if VARLINE.match(rhs) or VARLINE.match(t):
|
||||
return {"꼴": "변수", "조각": rhs.split("=")}
|
||||
if "=" not in rhs:
|
||||
m = re.match(r"^\s*(" + NUMRE + r")\s*\.?\s*$", rhs)
|
||||
if label and m:
|
||||
return {"꼴": "값", "라벨": label, "비목": label_comp(label), "값": num(m.group(1))}
|
||||
return {"꼴": "글"}
|
||||
segs = rhs.split("=")
|
||||
mres = RES.match(segs[-1]) or TAIL.match(segs[-1]) # 꼬리 글은 떼고 읽음(797-1a)
|
||||
if not mres:
|
||||
lead = re.match(r"\s*(" + NUMRE + r")", segs[-1])
|
||||
if (
|
||||
label and lead and re.search(r"\d", "=".join(segs[:-1]))
|
||||
): # 금액 줄 못 읽음 — 보인 값 보존
|
||||
return {"꼴": "글끝", "라벨": label, "보인": num(lead.group(1))}
|
||||
return {"꼴": "글끝"}
|
||||
shown = num(mres.group(1))
|
||||
toks = None
|
||||
for seg in reversed(segs[:-1]):
|
||||
toks = tokens(seg)
|
||||
if toks is None: # 라벨 없는 줄 — 왼쪽 글을 깎아 가며
|
||||
starts = {m.start() for m in re.finditer(NUMRE, seg)}
|
||||
starts |= {m.start() for m in re.finditer(r"\(\s*\d", seg)}
|
||||
for p in sorted(starts):
|
||||
if p and (seg[p - 1].isdigit() or seg[p - 1] in ".,"):
|
||||
continue
|
||||
toks = tokens(seg[p:])
|
||||
if toks:
|
||||
label = label or seg[:p]
|
||||
break
|
||||
if toks:
|
||||
break
|
||||
if not toks:
|
||||
return {"꼴": "식못읽음", "라벨": label, "값": shown}
|
||||
return {"꼴": "돈", "라벨": label, "비목": label_comp(label), "값": shown, "식": ex.parse(toks)}
|
||||
|
||||
|
||||
# ── 블록 풀이 ──────────────────────────────────────────────────────────
|
||||
def _ident(p: dict[str, Any]) -> tuple:
|
||||
return (p["종류"], p["이름"], p["규격"])
|
||||
|
||||
|
||||
def _money(L: dict, prices_by_value: dict, results: list) -> None:
|
||||
"""돈 줄 — 수 토큰 역할(단가 · 앞줄 · 상수) · 다시 셈 · 자리 규칙."""
|
||||
comp = L["비목"]
|
||||
nums = [(n[2], n[1]) for n in ex.numbers(L["식"])]
|
||||
roles: dict[int, dict[str, Any]] = {}
|
||||
price_at = None
|
||||
for i, v in nums:
|
||||
if v >= 1 and any(abs(r[1] - v) < EPS for r in results):
|
||||
continue # 앞 결과와 같은 수 = 참조(단가표 우연 일치보다 먼저)
|
||||
cands = [p for p in prices_by_value.get(v, []) if v >= 1 or (v == 0 and comp)]
|
||||
if comp:
|
||||
cands = [p for p in cands if p["비목"] == comp] or cands
|
||||
if v == 0 and comp and not cands:
|
||||
cands = [{"종류": "0", "이름": "-", "규격": "", "비목": comp, "값": 0.0}]
|
||||
first = i == nums[0][0]
|
||||
if cands and (v >= 100 or (first and v > 0)) or (v == 0 and cands):
|
||||
price_at = i
|
||||
roles[i] = {"갈래": "단가", "후보": cands}
|
||||
break
|
||||
for i, v in nums:
|
||||
if i in roles:
|
||||
continue
|
||||
hit = [r for r in reversed(results) if abs(r[1] - v) < EPS]
|
||||
roles[i] = {"갈래": "앞줄", "줄": hit[0][0]} if hit and v >= 1 else {"갈래": "상수"}
|
||||
L["역할"] = roles
|
||||
L["셈"] = calc(L["식"])
|
||||
L["규칙"] = ex.fit(L["셈"], L["값"])
|
||||
if price_at is not None:
|
||||
L["종류"] = "단가줄"
|
||||
pc = roles[price_at]["후보"]
|
||||
L["비목"] = L["비목"] or (pc[0]["비목"] if len({p["비목"] for p in pc}) == 1 else None)
|
||||
L["후보수"] = len({_ident(p) for p in pc})
|
||||
elif any(r["갈래"] == "앞줄" for r in roles.values()):
|
||||
L["종류"] = "참조줄"
|
||||
else:
|
||||
L["종류"] = "상수줄"
|
||||
|
||||
|
||||
def _value(L: dict, lines: list, results: list) -> None:
|
||||
"""값 줄 — 앞 결과 하나(자리 처리) 또는 바로 앞 돈 · 값 줄 이음 합."""
|
||||
v, how = L["값"], None
|
||||
for r in reversed(results):
|
||||
rule = ex.fit(r[1], v)
|
||||
if rule:
|
||||
how = {"갈래": "복사", "줄": [r[0]], "규칙": rule}
|
||||
break
|
||||
if not how and flat(L["라벨"]) in ("계", "합계"):
|
||||
got: dict[str, dict] = {}
|
||||
for x in reversed(lines):
|
||||
c = x.get("비목")
|
||||
if c and x.get("꼴") in ("돈", "값") and c not in got:
|
||||
got[c] = x
|
||||
if len(got) == 3:
|
||||
break
|
||||
if got:
|
||||
rule = ex.fit(sum(x["값"] for x in got.values()), v)
|
||||
if rule:
|
||||
how = {"갈래": "합", "줄": sorted(x["차례"] for x in got.values()), "규칙": rule}
|
||||
if not how:
|
||||
acc, used = 0.0, []
|
||||
for x in reversed([x for x in lines if x.get("꼴") in ("돈", "값")]):
|
||||
if len(used) >= 25:
|
||||
break
|
||||
acc += x["값"]
|
||||
used.append(x["차례"])
|
||||
rule = ex.fit(acc, v)
|
||||
if rule and len(used) > 1:
|
||||
how = {"갈래": "합", "줄": used[::-1], "규칙": rule}
|
||||
break
|
||||
L["종류"] = "값줄"
|
||||
L["숨은"] = how
|
||||
L["규칙"] = how["규칙"] if how else None
|
||||
|
||||
|
||||
def _var(L: dict) -> None:
|
||||
segs = L.pop("조각")
|
||||
m = re.match(r"^\s*(" + NUMRE + r")", segs[-1]) if len(segs) > 1 else None
|
||||
if not m:
|
||||
L["종류"] = "변수글"
|
||||
return
|
||||
shown, toks = num(m.group(1)), None
|
||||
for seg in reversed(segs[1:-1]):
|
||||
toks = tokens(seg)
|
||||
if toks:
|
||||
break
|
||||
L["값"] = shown
|
||||
L["규칙"] = ex.fit(calc(ex.parse(toks)), shown) if toks else None
|
||||
L["종류"] = "변수식" if toks else ("변수값" if len(segs) == 2 else "변수기호")
|
||||
|
||||
|
||||
def parse_block(text: list[str], prices: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""블록 글 줄 → 복원 줄 목록(역할 · 숨은 합 · 자리 규칙 · 단가 정체)."""
|
||||
by_value = collections.defaultdict(list)
|
||||
for p in prices:
|
||||
by_value[p["값"]].append(p)
|
||||
lines: list[dict[str, Any]] = []
|
||||
results: list[tuple] = [] # (차례, 값, 비목)
|
||||
for k, raw in enumerate(text):
|
||||
L = {"차례": k, "원글": raw, **parse_line(raw)}
|
||||
if L["꼴"] == "돈":
|
||||
_money(L, by_value, results)
|
||||
results.append((k, L["값"], L["비목"]))
|
||||
elif L["꼴"] == "값":
|
||||
_value(L, lines, results)
|
||||
results.append((k, L["값"], L["비목"]))
|
||||
elif L["꼴"] == "변수":
|
||||
_var(L)
|
||||
lines.append(L)
|
||||
identify(lines)
|
||||
return lines
|
||||
|
||||
|
||||
def identify(lines: list[dict[str, Any]]) -> None:
|
||||
"""단가 토큰 정체 — 값 하나 · 기계 세 줄 겹침 · 문맥(라벨 · 머리 글 이름) · 남으면 모호 표지."""
|
||||
head = ""
|
||||
for L in lines:
|
||||
if L["꼴"] in ("글", "글끝") or _HEADING.match(L["원글"]):
|
||||
head = L["원글"]
|
||||
if L.get("종류") != "단가줄":
|
||||
continue
|
||||
role = next(r for r in L["역할"].values() if r["갈래"] == "단가")
|
||||
cands = role["후보"]
|
||||
ids = {_ident(p) for p in cands}
|
||||
how = "값"
|
||||
if len(ids) > 1 and cands[0]["종류"] in ("기계", "산근"):
|
||||
k0, inter = L["차례"], set(ids)
|
||||
for M in lines[max(0, k0 - 3) : k0 + 4]:
|
||||
if M is L or M.get("종류") != "단가줄":
|
||||
continue
|
||||
r2 = next(r for r in M["역할"].values() if r["갈래"] == "단가")
|
||||
ids2 = {_ident(p) for p in r2["후보"]}
|
||||
if ids2 & inter and len(ids2 & inter) < len(inter):
|
||||
inter &= ids2
|
||||
ids, how = inter, "기계 세 줄"
|
||||
if len(ids) > 1:
|
||||
ctx = flat(L["원글"]) + "|" + flat(head)
|
||||
hit = {x for x in ids if x[1] and (x[1] in ctx or x[1][:3] in ctx)}
|
||||
ids, how = ({x for x in hit if not x[2] or x[2] in ctx} or hit or ids), "문맥"
|
||||
role["모호"] = len(ids) != 1
|
||||
role["갈림"] = how
|
||||
role["남은"] = len(ids)
|
||||
pick = sorted(ids)[0]
|
||||
p = next(p for p in cands if _ident(p) == pick)
|
||||
role["정체"] = {"종류": p["종류"], "이름": p["이름"], "규격": p["규격"], "비목": p["비목"]}
|
||||
|
||||
@@ -7,9 +7,8 @@ STmate 셈 규칙(`_분석_649-G2` 1 장) — 줄 금액 = 단가 × (계수 + 1
|
||||
T% = 바로 앞 계표시 값 × 수량 · 계표시 = 아래 단 누계 · `(-=)` = 찍되 위 합에서 뺌 ·
|
||||
`->` = 번지엔 쌓되 합에서 뺌 · 블록 끝전 두 갈래(비목 버림 / 계 버림 + 큰 나머지) ·
|
||||
결과금액 조정(SBM = 계를 한 비목으로) · 계약단가 = 비목 × 낙찰률 원 버림.
|
||||
EST 셈 규칙(같은 문서 2 장) — 줄 = 복원 식(단가 · 앞줄 결과 · 상수) → 줄 자리 속성 ·
|
||||
값 줄 = 숨은 복사 · 합 → 자리 속성 · 블록 성분 = 그 값을 낸 마지막 줄.
|
||||
단가 바뀜 전파 — STmate = 단가 dict 를 바꿔 다시 셈 · EST = `run_est(lines, {정체 열쇠: 새 값})`.
|
||||
단가 바뀜 전파 — 단가 dict 를 바꿔 다시 셈. 옛 EST 엑셀 셈 · 엑셀 검산(`run_est` · `est_check` ·
|
||||
`stmate_check`)은 812-1 엑셀 읽기기로 걷음(EST = `M02_Doc_NativeEst`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,7 +21,6 @@ from M02_MasterTemplete import M02_San_Expr as ex
|
||||
|
||||
COMPS = ("L", "M", "S")
|
||||
SPECIAL = ("L%", "M%", "S%", "T%")
|
||||
TOL_LINE = 0.05
|
||||
|
||||
|
||||
def _zero() -> dict[str, float]:
|
||||
@@ -179,115 +177,3 @@ def stmate_block(block: dict, nodes: list[dict], prices: dict, rule: str) -> dic
|
||||
fin = {c: math.floor(round(kye[c] * nag / 100, 6)) for c in COMPS}
|
||||
fin["T"] = fin["L"] + fin["M"] + fin["S"]
|
||||
return {"줄": lines, "합": tot, "계": kye, "금액": fin, "낙찰": nag}
|
||||
|
||||
|
||||
def stmate_check(block: dict, result: dict) -> dict[str, Any]:
|
||||
"""줄마다 · 블록 검산 — 금액 줄(참조 · 율 · 특수) C~F 대조 · 계표시 줄 · 블록 머리 · 꼬리 계."""
|
||||
bad, n_amt, n_tot, ok_tot = [], 0, 0, 0
|
||||
for r, ln in zip(result["줄"], block["몸"], strict=True):
|
||||
want = {"L": ln["D"] or 0, "M": ln["E"] or 0, "S": ln["F"] or 0}
|
||||
if r["종류"] in ("참조", "율", "특수"):
|
||||
n_amt += 1
|
||||
if not r["값"] or any(abs(r["값"][c] - want[c]) >= TOL_LINE for c in COMPS):
|
||||
bad.append({"행": ln["행"], "엑셀": want, "셈": r["값"], "오류": r["오류"]})
|
||||
elif r["종류"] == "계" and ln["C"] is not None:
|
||||
n_tot += 1
|
||||
ok_tot += all(abs(r["값"][c] - want[c]) < 0.15 for c in COMPS)
|
||||
head = block["머리"]
|
||||
want = {"T": head["C"] or 0, "L": head["D"] or 0, "M": head["E"] or 0, "S": head["F"] or 0}
|
||||
kye = next((t for t in block["꼬리"] if t["A"] == "계"), None)
|
||||
want_k = {"T": kye["C"], "L": kye["D"], "M": kye["E"], "S": kye["F"]} if kye else None
|
||||
same = lambda x, y: all(abs((x[k] or 0) - (y[k] or 0)) < 0.5 for k in "TLMS") # noqa: E731
|
||||
ok = same(result["금액"], want) and (want_k is None or same(result["계"], want_k))
|
||||
return {
|
||||
"맞음": ok,
|
||||
"엑셀": want,
|
||||
"엑셀계": want_k,
|
||||
"금액줄": n_amt,
|
||||
"틀린줄": bad,
|
||||
"계줄": n_tot,
|
||||
"계줄맞음": ok_tot,
|
||||
}
|
||||
|
||||
|
||||
# ── EST ────────────────────────────────────────────────────────────────
|
||||
def key_of(ident: dict[str, Any]) -> tuple:
|
||||
"""EST 단가 정체 → 전파 열쇠 (종류, 이름, 규격, 비목)."""
|
||||
return (ident["종류"], ident["이름"], ident["규격"], ident["비목"])
|
||||
|
||||
|
||||
def run_est(lines: list[dict], newp: dict[tuple, float] | None = None) -> dict[int, float | None]:
|
||||
"""복원 줄 → {차례: 다시 셈 값}(줄 자리 속성 그대로) · newp = {정체 열쇠: 새 단가}."""
|
||||
newp = newp or {}
|
||||
val: dict[int, float | None] = {}
|
||||
for L in lines:
|
||||
k = L["차례"]
|
||||
if L.get("꼴") == "돈" and L.get("규칙"):
|
||||
sub = {}
|
||||
for i, r in L["역할"].items():
|
||||
if r["갈래"] == "단가" and key_of(r["정체"]) in newp:
|
||||
sub[i] = newp[key_of(r["정체"])]
|
||||
elif r["갈래"] == "앞줄" and val.get(r["줄"]) is not None:
|
||||
sub[i] = val[r["줄"]]
|
||||
v = _est_calc(L["식"], sub)
|
||||
val[k] = ex.RULE[L["규칙"]](v) if v is not None else None
|
||||
elif L.get("종류") == "값줄" and L.get("숨은"):
|
||||
how = L["숨은"]
|
||||
s = sum(
|
||||
(val.get(j) if val.get(j) is not None else lines[j]["값"]) or 0 for j in how["줄"]
|
||||
)
|
||||
val[k] = ex.RULE[how["규칙"]](s)
|
||||
return val
|
||||
|
||||
|
||||
def _est_calc(node: tuple, sub: dict) -> float | None:
|
||||
try:
|
||||
return ex.value(node, {"V": {}, "sub": sub})
|
||||
except ex.ExprError:
|
||||
return None
|
||||
|
||||
|
||||
def designate(lines: list[dict], want: dict[str, float]) -> dict[str, Any]:
|
||||
"""블록 성분(총괄표) → 그 값을 낸 줄(마지막 · 라벨 비목 우선) · 0 = 「영」 · 없음 = None."""
|
||||
out: dict[str, Any] = {}
|
||||
kinds = ("단가줄", "참조줄", "상수줄", "값줄")
|
||||
for c in COMPS:
|
||||
w = want[c]
|
||||
if not w:
|
||||
out[c] = "영"
|
||||
continue
|
||||
cand = [L for L in lines if L.get("종류") in kinds and abs(L["값"] - w) < ex.EPS]
|
||||
pick = [L for L in cand if L.get("비목") == c] or cand
|
||||
out[c] = pick[-1]["차례"] if pick else None
|
||||
return out
|
||||
|
||||
|
||||
def est_block(lines: list[dict], des: dict[str, Any], newp: dict | None = None) -> dict[str, Any]:
|
||||
"""성분 줄 → 블록 금액(성분 원 버림 · 계 = 합) · 줄 값."""
|
||||
val = run_est(lines, newp)
|
||||
raw = {
|
||||
c: (0.0 if k == "영" else (val.get(k) if k is not None else None)) for c, k in des.items()
|
||||
}
|
||||
if any(v is None for v in raw.values()):
|
||||
return {"줄": val, "성분": raw, "금액": None}
|
||||
fin = {c: math.floor(round(raw[c], 6)) for c in COMPS}
|
||||
fin["T"] = fin["L"] + fin["M"] + fin["S"]
|
||||
return {"줄": val, "성분": raw, "금액": fin}
|
||||
|
||||
|
||||
def est_check(lines: list[dict], want: dict[str, float]) -> dict[str, Any]:
|
||||
"""블록 검산 — 다시 셈 성분 = 총괄표 · 줄마다 보인 값 대조(돈 · 값 줄)."""
|
||||
des = designate(lines, want)
|
||||
got = est_block(lines, des)
|
||||
val = got["줄"]
|
||||
n, bad = 0, []
|
||||
for L in lines:
|
||||
if L.get("꼴") == "돈" or L.get("종류") == "값줄":
|
||||
n += 1
|
||||
v = val.get(L["차례"])
|
||||
if v is None or abs(v - L["값"]) >= ex.EPS:
|
||||
bad.append({"차례": L["차례"], "글": L["원글"].strip(), "보인": L["값"], "셈": v})
|
||||
fin = got["금액"]
|
||||
ok = fin is not None and all(abs(fin[c] - want[c]) < 0.5 for c in COMPS)
|
||||
ok = ok and abs(want["L"] + want["M"] + want["S"] - want["T"]) < 0.5
|
||||
return {"맞음": ok, "성분줄": des, "금액": fin, "줄수": n, "틀린줄": bad}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""경쟁사 산근 줄 → M01 요소 이음(PLAN 814-1 · `M02_San_Emit` 에서 뗌 — 셈 꼴 글 · 표지 그대로).
|
||||
|
||||
`m01` = 1차 줄 판정 유일 짝(인력 · 재료 · 기계 · 유가 · 환율) · `hourly_text` = 기계 시간당 경비 부름 글 ·
|
||||
`combo_link` = 조합 기계(굴착기 + 브레이커 · 집게) 우리 조합 식 부름 글.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
NOTE = "참고 · " # `M02_San_Emit.NOTE` 와 같음(사람 확인 표지 머리)
|
||||
OIL_BASE = ("전국평균", "일반용(갑)Ⅰ 저압") # 유가 · 전력 기본(요구 748 · 우리 기초 기본값)
|
||||
QUOTE = "견적 — 그대로(이름 · 값) · [단가 갱신] 대상 아님" # 815-1 K5 · 요구 762-3
|
||||
|
||||
|
||||
def m01(kind: str, src: dict) -> tuple[str | None, str | None]:
|
||||
"""1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 ·
|
||||
환율만 — 재료 · 인력은 조용히 이음 없음) · 기계 = 기계 찾기(812-9) 못 이으면 늘 표지. 재료 줄 이름이 M01 유가전력(MO)이면
|
||||
유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님)."""
|
||||
if kind == "고정":
|
||||
return quote(src)
|
||||
if kind not in ("인력", "재료", "기계"):
|
||||
return None, None
|
||||
from M02_MasterTemplete import M02_Elem_Alias as ea
|
||||
from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||||
from M02_MasterTemplete import M02_Hopyo_Row as hrow
|
||||
|
||||
line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")}
|
||||
if (
|
||||
kind == "기계"
|
||||
): # 812-9 — 하나 = 이음 · 아니면 표지(후보 여럿 · 못 찾음 · 조용히 넘기지 않음)
|
||||
from M02_MasterTemplete import M02_Machine_Find as mfind
|
||||
|
||||
got = mfind.find(line["이름"], line["규격"])
|
||||
# 찾기 판정 없는 기계 줄 막음(814-1 · 줄 쌓기 표지 · 별칭)
|
||||
src.setdefault("찾기", {**got, "이름": got.get("이름") or line["이름"], "규격": got.get("규격") or line["규격"]}) # fmt: skip
|
||||
return got["키"], mfind.mark(got, line["이름"], line["규격"])
|
||||
if kind == "재료" and src.get("종류") == "자재" and (hit := ea.est_code(src.get("코드"))):
|
||||
return ea.one(line, hit) or hit[0], None # 815-1 K1 — EST 원파일 자재 = 우리 자재 출처 코드
|
||||
oil = ea.keys("MO", line["이름"], line["규격"]) if kind == "재료" else []
|
||||
if kind == "재료":
|
||||
if oil or elem.oil_rows(line["이름"]):
|
||||
kind = "유가"
|
||||
elif line["이름"].strip().upper() in {x.upper() for pair in hrow.CURRENCY for x in pair}:
|
||||
kind = "환율"
|
||||
if kind == "유가": # MO 전부 → 줄 규격이 상세구분(전국 · 지역)이면 그것만
|
||||
rows = [ea.row("MO", k) for k in oil] or elem.oil_rows(line["이름"]) # 815-1 별칭 먼저
|
||||
got = [{"키": r["키"], "규격": str(r.get("상세구분") or "")} for r in rows]
|
||||
spec = line["규격"].replace(" ", "")
|
||||
got = (
|
||||
[x for x in got if x["규격"] == spec]
|
||||
or [x for x in got if spec and spec in x["규격"]]
|
||||
or got
|
||||
)
|
||||
else:
|
||||
got = hrow.m01_picks(kind, line)
|
||||
keys = {x.get("키") for x in got if x.get("키")}
|
||||
if (
|
||||
len(keys) > 1 and kind == "유가"
|
||||
): # 814-1 — 전국평균 · 전력 = 일반용(갑)Ⅰ 저압(지역 · 계약종별은 옵션)
|
||||
keys = {x["키"] for x in got if x["규격"] in OIL_BASE} or keys
|
||||
elif len(keys) > 1 and kind == "재료": # 814-1 — 지역 꼬리 뗀 이름 · 규격 정확 일치
|
||||
keys = exact(line, got) or keys
|
||||
if not keys and kind == "재료": # 815-1 K3 — 자재표 전체에서 표기 정규화 정확 일치
|
||||
keys = {k} if (k := ea.one(line, ea.exact(line["이름"], line["규격"]))) else keys
|
||||
if len(keys) == 1:
|
||||
return keys.pop(), None
|
||||
if len(keys) > 1 and kind in ("유가", "환율"):
|
||||
return None, f"{NOTE}{kind} 후보 여럿({len(keys)}) · 사람 확인"
|
||||
return None, None
|
||||
|
||||
|
||||
def quote(src: dict) -> tuple[str | None, str | None]:
|
||||
"""견적 · 운임 · 수수료 꼴 줄(갈래 밖 — EST 견적 · STmate 고정 코드 · 815-1 K5) → 우리 자재에 이어지면
|
||||
(코드 · 별칭 · 정확 일치 · 단가 줄 · 우리 값 있을 때) 그 키 + 갈래 재료(우리 값 · 엑셀 값은 검산) ·
|
||||
아니면 그대로 + 표지."""
|
||||
from M02_MasterTemplete import M02_Elem_Alias as ea
|
||||
|
||||
src["견적"] = True
|
||||
line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")}
|
||||
key, _how = ea.material(line, src.get("코드"))
|
||||
if key and src.get("비목") and ea.priced(key): # 우리 값 없는 자재 = 견적 값 그대로
|
||||
src["갈래"] = "재료"
|
||||
return key, None
|
||||
return None, f"{NOTE}{QUOTE}"
|
||||
|
||||
|
||||
def exact(line: dict, got: list[dict]) -> set[str]:
|
||||
"""재료 후보 가운데 지역 꼬리 뗀 이름 · 규격이 줄과 정확히 같은 것(표기 정규화 `master_alias.norm`) —
|
||||
지역마다 여럿이면 줄 지역 › 서울 › 같은 단위 › 키 차례 첫째 하나 · 없음 = 빈 모음(사람 확인)."""
|
||||
from M02_MasterTemplete import M02_Elem_Alias as ea
|
||||
|
||||
want = ea._same(line.get("이름"), line.get("규격"))
|
||||
hits = [x["키"] for x in got if x.get("키") and ea._same(x.get("이름"), x.get("규격")) == want]
|
||||
return {k} if (k := ea.one(line, hits)) else set()
|
||||
|
||||
|
||||
def hourly_text(key: str | None) -> str | None:
|
||||
"""기계 EQ → 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 키 · 자료 없음 = None."""
|
||||
if not key:
|
||||
return None
|
||||
from M02_MasterTemplete import M02_Doc_Import as dim
|
||||
|
||||
got = dim.hourly_link(key, "노무비")
|
||||
if not got:
|
||||
return None
|
||||
return f"로직({got['키']}{''.join(f', {k}={v}' for k, v in got['입력'].items())})"
|
||||
|
||||
|
||||
def combo_link(one: dict, parts: list[dict]) -> str | None:
|
||||
"""조합 기계 둘이 우리 조합 식(GC000270 · 271) 고르기에 있으면 그 부름 글 · 줄 자원에 `조합식` 키."""
|
||||
from M02_MasterTemplete import M02_Machine_Hourly as mh
|
||||
|
||||
got = mh.combo(parts[0]["키"], parts[1]["키"]) if len(parts) == 2 and all(p["키"] for p in parts) else None # fmt: skip
|
||||
if got:
|
||||
one["자원"]["조합식"] = got[0]
|
||||
return got[1] if got else None
|
||||
|
||||
|
||||
# ── 못 이은 줄 까닭(814-1 · 요구 745 C2) ─────────────────────────────────
|
||||
FREIGHT = ("운임", "화물", "운반비", "하차")
|
||||
|
||||
|
||||
def why(item: dict) -> str:
|
||||
"""이음 없는 가져온 줄 → 까닭 한 마디(문서 설명 · 띠 · 원문 카드) — 「없음」은 흠이 아니라 우리 정보로
|
||||
못 이은 까닭(자재 없음 · 운임표 없음 · 기계 표에 없음 · 별칭 대기 · 후보 여럿 · 시간당 경비 자료 없음 · 갈래 밖)."""
|
||||
res = item.get("자원") or {}
|
||||
kind = res.get("갈래") or item.get("종류")
|
||||
name = str(res.get("이름") or item.get("이름") or "")
|
||||
if res.get("조합식"):
|
||||
return f"조합 — 우리 조합 식 {res['조합식']} 에 듦"
|
||||
if kind == "기계":
|
||||
found = res.get("찾기") or {}
|
||||
verdict = found.get("판정")
|
||||
if verdict == "못 찾음":
|
||||
return (
|
||||
"별칭 대기"
|
||||
if str(found.get("까닭") or "").startswith("기종 모름")
|
||||
else "기계 표에 없음"
|
||||
)
|
||||
if verdict == "후보 여럿":
|
||||
return "기계 후보 여럿 — 고름"
|
||||
return "시간당 경비 자료 없음" if found.get("키") else "기계 찾기 없음"
|
||||
if kind == "재료":
|
||||
from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||||
from M02_MasterTemplete import M02_Hopyo_Row as hrow
|
||||
|
||||
if elem.oil_rows(name):
|
||||
return "유가 후보 여럿 — 고름"
|
||||
line = {"이름": name, "규격": res.get("규격") or "", "단위": res.get("단위") or ""}
|
||||
if hrow.m01_picks("재료", line):
|
||||
return "자재 후보 여럿 — 고름"
|
||||
return "운임표 없음" if any(w in name for w in FREIGHT) else "자재 없음(우리 자재표)"
|
||||
if kind == "인력":
|
||||
return "노임 없음"
|
||||
if kind == "단가산출":
|
||||
return "안쪽 단가산출 못 이음"
|
||||
return QUOTE
|
||||
|
||||
|
||||
def tally(logic: dict) -> dict:
|
||||
"""우리 정보로 셈 — 자원 줄 {됨, 줄, 조합(형제 단가산출 부름뿐)} (연결 판정 「없음」 띠 · 814-1)."""
|
||||
items = logic.get("호표") or []
|
||||
done = sum(
|
||||
1 for x in items if x.get("이음") or x.get("참조") or (x.get("자원") or {}).get("조합식")
|
||||
)
|
||||
inner = [x for x in items if (x.get("자원") or {}).get("갈래") == "단가산출" or x.get("참조")]
|
||||
return {"됨": done, "줄": len(items), "조합": bool(items) and len(inner) == len(items)}
|
||||
@@ -0,0 +1,175 @@
|
||||
"""경쟁사 산근 엑셀(.xlsx · .xls) 읽기기 — 810-1 원파일 파서와 같은 「구조 줄」 꼴(PLAN 812-1 · 요구 711-2 ·
|
||||
712 · 713 · 읽기만 · 서버 단독).
|
||||
|
||||
`read(name, data)` = `M02_San_Estx.read` · `M02_San_Stc.read` 와 같은 칸 이름 · 같은 뜻
|
||||
(칸 뜻 `resources/master_data/_원파일파서_틀.md`) → 그 뒤는 원파일 길(`M02_Doc_FromNative.pack` →
|
||||
`emit` → `M02_Doc_NativeEst` · `M02_San_Emit.stmate` → 수식 문서) 그대로.
|
||||
EST = `M02_San_Xlsx_Est`(단가산출 시트 글 · 금액 칸 · 같은 책 단가표) ·
|
||||
STmate = `M02_San_Xlsx_Stmate`(숨은 G 열 원문 = 원파일 원문 · 단가 = 목록표 · 끝전 = 파일 투표).
|
||||
엑셀이 못 주는 칸(자원 코드 · LinkIndex · 자리 Diget)은 비움 + 표지 「엑셀 — …」 · 글에서 읽어 정한 것은
|
||||
`연결뜻` 에 적고 줄 `추정` 참 + 블록 표지 「엑셀 추정」.
|
||||
여기 = 공통(시트 → 글 줄 · 금액 칸 · 자원 표).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
|
||||
GUESS = "엑셀 추정"
|
||||
NOTE = "엑셀 — "
|
||||
|
||||
|
||||
class XlsxError(ValueError):
|
||||
"""산근 시트가 없는 엑셀."""
|
||||
|
||||
|
||||
def flat(v: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(v or ""))
|
||||
|
||||
|
||||
def number(v: Any) -> float | None:
|
||||
"""칸 값 → 수(「3,442」 · 「8.」 · 수 칸) · 아니면 None."""
|
||||
if isinstance(v, bool) or v is None:
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
s = str(v).strip().replace(",", "")
|
||||
if not re.fullmatch(r"-?\d+(?:\.\d*)?|-?\.\d+", s):
|
||||
return None
|
||||
return float(s.rstrip(".") or 0)
|
||||
|
||||
|
||||
def _cell(v: Any) -> str:
|
||||
if isinstance(v, float) and v == int(v):
|
||||
return str(int(v))
|
||||
return "" if v is None else str(v)
|
||||
|
||||
|
||||
def _width(ch: str) -> int:
|
||||
return 2 if unicodedata.east_asian_width(ch) in "WFA" else 1
|
||||
|
||||
|
||||
def layout(cells: list[tuple[int, str]], base: int) -> str:
|
||||
"""글자 칸 나눔 줄(거창 꼴 · 열 = 화면 자리) → 한 줄 글(열 차례 · 모자란 자리 = 빈칸 · 겹친 자리(수 끝 점)
|
||||
= 빈칸 하나)."""
|
||||
out, end = "", 0
|
||||
for col, text in cells:
|
||||
want = col - base
|
||||
if want > end:
|
||||
out += " " * (want - end)
|
||||
elif out and want < end and not out.endswith(" "):
|
||||
out += " "
|
||||
out += text
|
||||
end = want + sum(_width(c) for c in text)
|
||||
return out
|
||||
|
||||
|
||||
# ── 시트 머리 ──────────────────────────────────────────────────────────
|
||||
MONEY = ("합계", "노무비", "재료비", "경비")
|
||||
|
||||
|
||||
def money_cols(rows: list[list[Any]]) -> list[int] | None:
|
||||
"""머리 줄의 맨 오른쪽 「합계 · 노무비 · 재료비 · 경비」 네 칸 열 → [T L M S]."""
|
||||
for r in rows[:6]:
|
||||
names = [flat(v) for v in r]
|
||||
hits = [j for j in range(len(names) - 3) if tuple(names[j : j + 4]) == MONEY]
|
||||
if hits:
|
||||
return list(range(hits[-1], hits[-1] + 4))
|
||||
return None
|
||||
|
||||
|
||||
def money_of(row: list[Any], cols: list[int]) -> dict[str, float] | None:
|
||||
got = {c: number(row[j]) if j < len(row) else None for c, j in zip("TLMS", cols)}
|
||||
got = {c: v for c, v in got.items() if v is not None}
|
||||
return got or None
|
||||
|
||||
|
||||
def head_col(rows: list[list[Any]], *words: str) -> int | None:
|
||||
for r in rows[:6]:
|
||||
for j, v in enumerate(r):
|
||||
if flat(v) in words:
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
# ── 자원 표(EST) ───────────────────────────────────────────────────────
|
||||
def _table(rows: list[list[Any]], price: tuple[str, ...]) -> list[dict[str, Any]]:
|
||||
"""단가 시트 → [{번, 이름, 규격, 단위, 줄 (원 줄)}](품명 · 규격 · 단위 · 값 열 = 머리 글)."""
|
||||
c_no, c_name = head_col(rows, "No"), head_col(rows, "품명")
|
||||
c_size, c_unit = head_col(rows, "규격"), head_col(rows, "단위")
|
||||
if c_name is None:
|
||||
return []
|
||||
out = []
|
||||
for r in rows:
|
||||
no = str(r[c_no]).strip() if c_no is not None and c_no < len(r) else ""
|
||||
if not no.isdigit() or c_name >= len(r) or not str(r[c_name] or "").strip():
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"번": int(no),
|
||||
"이름": str(r[c_name]).strip(),
|
||||
"규격": str(r[c_size] or "").strip() if c_size is not None else "",
|
||||
"단위": str(r[c_unit] or "").strip() if c_unit is not None else "",
|
||||
"줄": r,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def resources(sheets: dict[str, list]) -> dict[str, list[dict[str, Any]]]:
|
||||
"""같은 책 단가표 → {종류: [{번 · 이름 · 규격 · 단위 · 표 · 값 {T L M S}}]}(노임 · 자재 = 적용단가 ·
|
||||
기계 · 견적 · 산근 = 적용 칸(합이 안 맞으면 당초 · `M02_San_Book.applied`))."""
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
for kind, sheet, comp in (("노임", "노임단가", "L"), ("자재", "자재단가", "M")):
|
||||
rows = sheets.get(sheet) or []
|
||||
c = head_col(rows, "적용단가")
|
||||
got = []
|
||||
for one in _table(rows, ("적용단가",)):
|
||||
v = number(one["줄"][c]) if c is not None and c < len(one["줄"]) else None
|
||||
if v is not None:
|
||||
got.append({**_drop(one), "표": sheet, "값": {"T": v, comp: v}})
|
||||
out[kind] = got
|
||||
for kind, sheet in (
|
||||
("기계", "기계경비총괄표"),
|
||||
("견적", "견적단가"),
|
||||
("산근", "단가산출총괄표"),
|
||||
):
|
||||
got = []
|
||||
for one in _table(sheets.get(sheet) or [], ()):
|
||||
r = list(one["줄"]) + [None] * 30
|
||||
vals, note = book.applied_how(r)
|
||||
vals = {k: float(v or 0) for k, v in vals.items()}
|
||||
got.append({**_drop(one), "표": sheet, "값": vals} | ({"칸": note} if note else {}))
|
||||
out[kind] = got
|
||||
return out
|
||||
|
||||
|
||||
def _drop(one: dict) -> dict:
|
||||
return {k: v for k, v in one.items() if k != "줄"}
|
||||
|
||||
|
||||
def kind_of(sheets: dict[str, list]) -> str | None:
|
||||
return book.kind_of(sheets)
|
||||
|
||||
|
||||
def read(name: str, data: bytes) -> dict[str, Any]:
|
||||
"""엑셀 → {꼴, 산근, 일위, 자원, 내역, 표지(, 단가 · 옵션 STmate)} — 산근 시트 없음 = XlsxError."""
|
||||
sheets = book.sheets(name, data)
|
||||
return read_sheets(sheets)
|
||||
|
||||
|
||||
def read_sheets(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
kind = kind_of(sheets)
|
||||
if kind == "EST":
|
||||
from M02_MasterTemplete import M02_San_Xlsx_Est as xe
|
||||
|
||||
return xe.read(sheets) | {"엑셀": True}
|
||||
if kind == "STmate":
|
||||
from M02_MasterTemplete import M02_San_Xlsx_Stmate as xs
|
||||
|
||||
return xs.read(sheets) | {"엑셀": True}
|
||||
raise XlsxError("STmate · EST 산근 시트 없음")
|
||||
@@ -0,0 +1,663 @@
|
||||
"""EST 산근 엑셀 → 810-1 구조 줄(PLAN 812-1 · `M02_San_Estx.cost_line` 과 같은 칸 · 읽기만 · 서버 단독).
|
||||
|
||||
엑셀 「단가산출」 시트 = 원파일 LineData 를 보인 글(제어문자 표지 없음) + 줄 금액 칸(원파일 줄 TC NC GC JC ·
|
||||
0 = 빈칸 · 소계 61 = 비목 칸만) → 표지를 글 · 금액 칸 · 같은 책 단가표(노임 · 자재 · 기계 · 견적 · 단가산출
|
||||
총괄표)로 다시 정함(`연결뜻` · 표지 「엑셀 추정」 · 자원 코드 · 자리 = 비움):
|
||||
변수 줄 「이름 = 식 = 값」 → 값 = 결과(`\\x12`) · 「이름 = 값」 → 대입(결과 없음) · 금액 줄 첫 식 조각의 수가
|
||||
앞 대입 값과 같으면 변수(`\\x11` · 상수 아님) ·
|
||||
합계 머리(「합 계」) 아래 노무비 · 재료비 · 경비 · 계 줄 = 합계(`\\x1a` · 비목 L M S T) ·
|
||||
「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격) = 세 줄 머리(104 · 101 · 105) → 아래
|
||||
「노무비 · 재료비 · 경비 : 단가 …」(단가 = 그 자원 비목 값) = 세 줄 · 「소 계」 = 계 줄 ·
|
||||
노임 · 자재 이름 + 그 단가 수 = 자원 줄(5 · 6 · 비목 = 금액 칸) ·
|
||||
「…소계 : 수」(금액 칸 = 비목만) = 소계 61(줄참조) · 「수 × n% = 값」 + 금액 칸 = 율 58(줄참조) ·
|
||||
「(A 수 + 수) × n%」 = 율 62(구간 합) · 「= A수」 = 구간 표지(`\\x06A\\x06`) · 그 밖 = 글 줄.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
|
||||
SHEET = "단가산출"
|
||||
LINK = {"자재": 5, "노임": 6, "견적": 101, "산근": 104, "기계": 105, "소계": 61, "율": 58}
|
||||
MEAN = {5: "자재", 6: "노임", 101: "견적", 104: "산근", 105: "기계", 61: "소계", 58: "율"}
|
||||
MEAN[62] = "율"
|
||||
LABEL = {"노무비": "L", "재료비": "M", "경비": "S", "계": "T"}
|
||||
NUM = r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+" # 「.1」 = 엑셀 꼴 0.1
|
||||
_NUM = re.compile(r"(?<![A-Za-z0-9_.,])(-?(?:" + NUM + r"))\.?(?![\d,])")
|
||||
_HEAD = re.compile(r"^(\d+)\.(.*?)\[([^\[\]]*)\]\s*$")
|
||||
_HEADING = re.compile(r"^\s*(?:\(\d+\)|(?:\d+|[가-하])\s*[.)])\s*")
|
||||
_REF = re.compile(r"\*\s*(단산|견적)\s*(\d+)\s*참조\s*\*")
|
||||
_ASSIGN = re.compile(r"(?<![A-Za-z0-9_])([A-Za-z][A-Za-z0-9]*)\s*(?:\([^)=]*\))?\s*=(?!=)")
|
||||
_GROUP62 = re.compile(r"^\s*\(?\s*([A-Z])\s*(" + NUM + r")((?:\s*\+\s*(?:" + NUM + r"))*)")
|
||||
_UNITS = {"hr", "m", "km", "kg", "ton", "sec", "min", "mm", "cm", "ea", "t"}
|
||||
_COLON = re.compile(r"[::]")
|
||||
|
||||
|
||||
def _v(s: str) -> float:
|
||||
return float(s.replace(",", "").rstrip("."))
|
||||
|
||||
|
||||
def _close(a: float, b: float) -> bool:
|
||||
return abs(a - b) < 1e-6 * max(1.0, abs(b))
|
||||
|
||||
|
||||
def _span(text: str, name: str, start: int = 0) -> tuple[int, int] | None:
|
||||
"""빈칸을 건너 같은 글자 차례 자리(이름 · 규격) · 없음 None."""
|
||||
chars = [re.escape(c) for c in sx.flat(name)]
|
||||
if not chars:
|
||||
return None
|
||||
m = re.compile(r"\s*".join(chars)).search(text, start)
|
||||
return (m.start(), m.end()) if m else None
|
||||
|
||||
|
||||
# ── 시트 → 블록 ───────────────────────────────────────────────────────
|
||||
def blocks(rows: list[list[Any]]) -> list[dict[str, Any]]:
|
||||
"""단가산출 시트 → [{번, 머리, 행, 저장, 줄 [{행, 엑셀행, 보임, 돈}]}](머리 = B 열 「N.이름[단위]」)."""
|
||||
cols = sx.money_cols(rows) or [2, 3, 4, 5]
|
||||
split = cols[0] > 6 # 거창 꼴 — 글자 칸 나눔(열 = 화면 자리 · B 열 = 머리만)
|
||||
out: list[dict[str, Any]] = []
|
||||
for i, r in enumerate(rows):
|
||||
head = r[1] if len(r) > 1 else None
|
||||
m = _HEAD.match(head) if isinstance(head, str) and not head[:1].isspace() else None
|
||||
if m and i > 2:
|
||||
out.append({"번": int(m.group(1)), "머리": head, "행": i + 1, "줄": []})
|
||||
out[-1]["저장"] = sx.money_of(r, cols)
|
||||
continue
|
||||
if not out:
|
||||
continue
|
||||
if split:
|
||||
cells = [(j, sx._cell(v)) for j, v in enumerate(r[2 : cols[0]], 2)]
|
||||
text = sx.layout([(j, v) for j, v in cells if v.strip()], 2)
|
||||
else:
|
||||
text = sx._cell(head)
|
||||
one = {"행": len(out[-1]["줄"]) + 1, "엑셀행": i + 1, "보임": text, "칸": split}
|
||||
out[-1]["줄"].append(one | {"돈": sx.money_of(r, cols)})
|
||||
for b in out: # 끝 빈 줄(시트 꼬리)
|
||||
while b["줄"] and not b["줄"][-1]["보임"].strip() and not b["줄"][-1]["돈"]:
|
||||
b["줄"].pop()
|
||||
return out
|
||||
|
||||
|
||||
# ── 줄 ────────────────────────────────────────────────────────────────
|
||||
def _label(text: str) -> str | None:
|
||||
"""「노무비 :」 · 「경 비 :」 · 「계 :」 → 비목 글자 · 아니면 None."""
|
||||
m = _COLON.search(text)
|
||||
if not m:
|
||||
return None
|
||||
return LABEL.get(sx.flat(_HEADING.sub("", text[: m.start()])))
|
||||
|
||||
|
||||
def _nums(text: str, lo: int = 0, hi: int | None = None) -> list[tuple[int, int, float]]:
|
||||
hi = len(text) if hi is None else hi
|
||||
return [(m.start(1), m.end(1), _v(m.group(1))) for m in _NUM.finditer(text, lo, hi)]
|
||||
|
||||
|
||||
def _result(text: str) -> tuple[int, int, float, int | None] | None:
|
||||
"""마지막 「=」 뒤 첫 수(구간 글자 「A」 붙음 허용) → (시작, 끝, 값, 글자 자리)."""
|
||||
eq = text.rfind("=")
|
||||
if eq < 0:
|
||||
return None
|
||||
m = re.match(r"\s*(?:([A-Z])\s*)?(-?(?:" + NUM + r"))\.?(?![\d,])", text[eq + 1 :])
|
||||
if not m:
|
||||
return None
|
||||
at = eq + 1
|
||||
return at + m.start(2), at + m.end(2), _v(m.group(2)), at + m.start(1) if m.group(1) else None
|
||||
|
||||
|
||||
class Lines:
|
||||
"""블록 한 벌 줄 → 구조 줄."""
|
||||
|
||||
def __init__(self, res: dict[str, list[dict]], tags: list[str]) -> None:
|
||||
self.res, self.tags = res, tags
|
||||
self.vars: dict[str, float] = {} # 이름 → 마지막 대입 값
|
||||
self.ctx: dict | None = None # 세 줄 머리 자원 {종류, 자원}
|
||||
self.triple = False # 바로 앞이 세 줄
|
||||
self.heads: list[dict] = [] # 세 줄 머리 줄들
|
||||
self.lines: list[dict[str, Any]] = []
|
||||
|
||||
# ── 꼴 ─────────────────────────────────────────────────────────
|
||||
def blank(self, src: dict) -> dict[str, Any]:
|
||||
return {
|
||||
"행": src["행"],
|
||||
"엑셀행": src["엑셀행"],
|
||||
"글": src["보임"],
|
||||
"보임": src["보임"],
|
||||
"품명": None,
|
||||
"규격": None,
|
||||
"단위": None,
|
||||
"단": 0,
|
||||
"색인": 0,
|
||||
"연결": 0,
|
||||
"연결뜻": None,
|
||||
"비목": None,
|
||||
"자원": None,
|
||||
"연결단가": [],
|
||||
"변수": [],
|
||||
"줄참조": [],
|
||||
"결과": [],
|
||||
"합계": False,
|
||||
"자리": {"자리수": None, "방법": None},
|
||||
"표지": None,
|
||||
"기계": None,
|
||||
"산근": None,
|
||||
"저장": src.get("돈"),
|
||||
"추정": False,
|
||||
"_marks": [],
|
||||
"_칸": src.get("칸"),
|
||||
}
|
||||
|
||||
def resource(self, kind: str, one: dict) -> dict[str, Any]:
|
||||
code = (
|
||||
f"단산{one['번']}" if kind == "산근" else f"{one['표']}#{one['번']}"
|
||||
) # 원 코드 없음 = 표 줄 번
|
||||
out = {"코드": code, "종류": kind, "표": one["표"]}
|
||||
return out | {k: one[k] for k in ("이름", "규격", "단위")} | {"저장": dict(one["값"])}
|
||||
|
||||
def link(self, ln: dict, kind: str, one: dict | None) -> None:
|
||||
ln["연결"] = LINK[kind] if kind in LINK else 62
|
||||
ln["연결뜻"] = MEAN.get(ln["연결"])
|
||||
ln["추정"] = True
|
||||
if one is not None:
|
||||
ln["자원"] = self.resource(kind, one)
|
||||
if kind == "산근":
|
||||
ln["산근"] = ln["자원"]["코드"]
|
||||
|
||||
@staticmethod
|
||||
def money_part(ln: dict) -> str | None:
|
||||
"""금액 칸 비목 — 노 · 재 · 경 중 값 하나 · 셋 다 = T · 없음 = None."""
|
||||
got = [c for c in "LMS" if (ln["저장"] or {}).get(c)]
|
||||
return got[0] if len(got) == 1 else ("T" if got else None)
|
||||
|
||||
@staticmethod
|
||||
def closed(text: str, end: int) -> bool:
|
||||
"""칸 나눔 꼴 — 표지 붙은 수 뒤는 닫는 표지 2 칸(빈칸 · 수 끝 점 2 이상 · 줄 끝)."""
|
||||
gap = len(text[end:]) - len(text[end:].lstrip(" ."))
|
||||
return gap >= 2 or not text[end:].strip()
|
||||
|
||||
def wrap(self, ln: dict, s: int, e: int, ch: str) -> None:
|
||||
ln["_marks"].append((s, e, ch))
|
||||
|
||||
# ── 자원 찾기 ─────────────────────────────────────────────────
|
||||
def names(self, ln: dict, one: dict, start: int = 0) -> int | None:
|
||||
"""이름 · 규격 · 단위 자리 표지(`\\x0e` · `\\x13` · `\\x15`) · 끝 자리."""
|
||||
text = ln["보임"]
|
||||
sp = _span(text, one["이름"], start)
|
||||
if not sp:
|
||||
return None
|
||||
self.wrap(ln, *sp, "\x0e")
|
||||
ln["품명"] = text[sp[0] : sp[1]]
|
||||
end = sp[1]
|
||||
if one.get("규격"):
|
||||
m = re.match(r"\s*", text[end:])
|
||||
at = end + m.end()
|
||||
size = _span(text, one["규격"], at)
|
||||
if size and size[0] - at <= 1:
|
||||
lo = size[0] - (1 if text[size[0] - 1 : size[0]] in "([" and size[0] > at else 0)
|
||||
hi = size[1] + (1 if text[size[1] : size[1] + 1] in ")]" and lo < size[0] else 0)
|
||||
self.wrap(ln, lo, hi, "\x13")
|
||||
ln["규격"] = text[lo:hi]
|
||||
end = hi
|
||||
unit = re.match(r"\s*(\[[^\[\]]*\])", text[end:])
|
||||
if unit:
|
||||
self.wrap(ln, end + unit.start(1), end + unit.end(1), "\x15")
|
||||
ln["단위"] = unit.group(1)
|
||||
end += unit.end(1)
|
||||
return end
|
||||
|
||||
def find_item(self, ln: dict) -> tuple[str, dict, tuple[int, int, float]] | None:
|
||||
"""노임 · 자재 — 글 속 수가 그 단가이고 이름이 그 수 앞에 있음(가장 긴 이름 · 규격 맞음 먼저)."""
|
||||
text, body = ln["보임"], sx.flat(ln["보임"])
|
||||
best = None
|
||||
for hit in _nums(text):
|
||||
for kind, one in self.res["_값"].get(round(hit[2], 4), ()):
|
||||
name = sx.flat(one["이름"])
|
||||
if not name or name not in body:
|
||||
continue
|
||||
sp = _span(text, one["이름"])
|
||||
if not sp or sp[1] > hit[0]:
|
||||
continue
|
||||
key = (not one["규격"] or sx.flat(one["규격"]) in body, len(name))
|
||||
if best is None or key > best[0]:
|
||||
best = (key, kind, one, hit)
|
||||
return best[1:] if best else None
|
||||
|
||||
def find_head(self, ln: dict) -> tuple[str, dict] | None:
|
||||
"""세 줄 머리 — 「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격)."""
|
||||
text = ln["보임"]
|
||||
m = _REF.search(text)
|
||||
if m:
|
||||
kind = "산근" if m.group(1) == "단산" else "견적"
|
||||
one = self.res["_번"].get((kind, int(m.group(2))))
|
||||
if kind == "견적" and (not one or sx.flat(one["이름"]) not in sx.flat(text)):
|
||||
body = sx.flat(text[: m.start()]) # 견적 번 ≠ 표 차례 — 이름으로(가장 긴 것)
|
||||
hits = [x for x in self.res.get("견적") or [] if sx.flat(x["이름"]) in body]
|
||||
one = (
|
||||
max(hits, key=lambda x: len(sx.flat(x["이름"]) + sx.flat(x["규격"])))
|
||||
if hits
|
||||
else None
|
||||
)
|
||||
return (kind, one) if one else None
|
||||
if "=" in text or self.res["_기계틀"] is None or text.lstrip()[:1] in ("※", "*"):
|
||||
return None
|
||||
body, best = sx.flat(text), None
|
||||
for at in self.res["_기계틀"].finditer(body):
|
||||
name = at.group(1)
|
||||
for one in self.res["_기계"][name]:
|
||||
size = sx.flat(one["규격"])
|
||||
tail = body[at.start() + len(name) : at.start() + len(name) + len(size) + 2]
|
||||
if size and size not in tail:
|
||||
continue
|
||||
key = len(name) + len(size)
|
||||
if best is None or key > best[0]:
|
||||
best = (key, one)
|
||||
return ("기계", best[1]) if best else None
|
||||
|
||||
# ── 줄 갈래 ───────────────────────────────────────────────────
|
||||
def run(self, src: dict) -> dict[str, Any]:
|
||||
ln = self.blank(src)
|
||||
text = ln["보임"]
|
||||
if not text.strip() and not ln["저장"]:
|
||||
ln["연결"] = None
|
||||
return self.done(ln)
|
||||
heading = bool(_HEADING.match(text))
|
||||
part = _label(text)
|
||||
if heading:
|
||||
ln["단"] = 1
|
||||
title = sx.flat(_HEADING.sub("", text))
|
||||
if re.search(r"[합소총]계|^계", title):
|
||||
self.heads = [] # 합계 절 — 세 줄 머리 닫음
|
||||
self.triple = False
|
||||
if self.triple and "소계" in sx.flat(text.split(":")[0]) and self.ctx:
|
||||
return self.done(self.triple_total(ln))
|
||||
self.triple = False
|
||||
if part in ("L", "M", "S") and not heading and self.triple_line(ln, part):
|
||||
return self.done(ln)
|
||||
got = self.find_item(ln)
|
||||
total = ln["저장"] if part == "T" else not ln["저장"] # 합계 = 계 줄만 금액 칸
|
||||
if part and not heading and got is None and total:
|
||||
return self.done(self.total_line(ln, part))
|
||||
if got is not None:
|
||||
return self.done(self.item(ln, *got))
|
||||
if (head := self.find_head(ln)) is not None:
|
||||
kind, one = head
|
||||
self.link(ln, kind, one)
|
||||
end = self.names(ln, one)
|
||||
loose = end is None or (one["규격"] and ln["규격"] is None) # 규격이 이름 곁에 없음
|
||||
loose = loose or text[end or 0 :].lstrip()[:1] in (":", ":")
|
||||
if ln["단"] == 0 and kind == "기계" and loose: # 설명 글 속 장비 이름
|
||||
ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None}
|
||||
ln |= {"규격": None, "단위": None, "_marks": []}
|
||||
self.plain(ln)
|
||||
return self.done(ln)
|
||||
self.heads.append({"종류": kind, "자원": one, "줄": ln, "씀": False})
|
||||
self.ctx = self.heads[-1]
|
||||
return self.done(ln)
|
||||
if self.subtotal(ln) or self.rate62(ln) or self.rate(ln):
|
||||
return self.done(ln)
|
||||
self.plain(ln)
|
||||
return self.done(ln)
|
||||
|
||||
def sections(self, saved: dict) -> None:
|
||||
"""절 소계(가 · 나 … 머리 아래 합계 줄 「노무비 : a + b = X」)가 뺀 줄 하나 = 합에 안 든 줄(비목 없음) —
|
||||
절(앞 숫자 머리부터) 안 그 비목 줄 결과 합 − X 가 꼭 한 줄 값이고 나머지 줄 값은 모두 덧셈 글에 있을
|
||||
때만 · 블록 금액도 그 줄을 빼야 맞을 때만(엑셀 글 덧셈 · 원파일 SCheck 0)."""
|
||||
lines, start = self.lines, 0
|
||||
for i, ln in enumerate(lines):
|
||||
text = ln["보임"]
|
||||
if ln["단"] == 1 and re.match(r"^\s*\d", text):
|
||||
start = i
|
||||
if not (ln["합계"] and ln["비목"] in ("L", "M", "S") and ln["결과"] and "+" in text):
|
||||
continue
|
||||
head = next((k for k in range(i - 1, start - 1, -1) if lines[k]["단"] == 1), None)
|
||||
if head is None or not re.match(r"^\s*[가-하]", lines[head]["보임"]):
|
||||
continue
|
||||
part, want = ln["비목"], ln["결과"][-1]
|
||||
kids = [
|
||||
x for x in lines[start:head] if x["비목"] == part and x["결과"] and not x["합계"]
|
||||
]
|
||||
kids += [
|
||||
x for x in lines[start:head] if x["비목"] == part and x["줄참조"] and not x["결과"]
|
||||
]
|
||||
value = {id(x): (x["결과"] or x["줄참조"])[-1] for x in kids}
|
||||
extra = sum(value.values()) - want
|
||||
odd = [x for x in kids if abs(value[id(x)] - extra) < 0.5]
|
||||
colon = _COLON.search(text)
|
||||
adds = [v for _, _, v in _nums(text, colon.end() if colon else 0, text.rfind("="))]
|
||||
rest = [value[id(x)] for x in kids if odd and x is not odd[0]]
|
||||
listed = all(any(_close(v, a) for a in adds) for v in rest)
|
||||
if len(odd) != 1 or abs(extra) < 0.5 or not listed:
|
||||
continue
|
||||
every = sum(
|
||||
(x["결과"] or x["줄참조"])[-1]
|
||||
for x in lines
|
||||
if x["비목"] == part and not x["합계"] and (x["결과"] or x["줄참조"])
|
||||
)
|
||||
block = float(saved.get(part) or 0) # 블록 금액이 그 줄을 빼야 맞을 때만
|
||||
if abs(every - block) >= 1 and abs(every - extra - block) < 1:
|
||||
odd[0]["비목"] = None
|
||||
|
||||
def unused(self) -> None:
|
||||
"""머리 아닌 줄의 기계 이름 — 아래 세 줄이 안 쓰면 글 줄(설명 속 장비 이름)."""
|
||||
for h in self.heads:
|
||||
ln = h["줄"]
|
||||
if h["종류"] == "기계" and not h["씀"] and ln["단"] == 0:
|
||||
ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None}
|
||||
ln |= {"규격": None, "단위": None, "_marks": []}
|
||||
|
||||
def done(self, ln: dict) -> dict[str, Any]:
|
||||
self.lines.append(ln)
|
||||
return ln
|
||||
|
||||
def total_line(self, ln: dict, part: str) -> dict:
|
||||
"""합계 줄 — 「노무비 : 식 = 값」 · 「재료비 : 값」 · 「계 : 값」(계 = 결과)."""
|
||||
text = ln["보임"]
|
||||
ln |= {"합계": True, "비목": part, "단": -1}
|
||||
lead = len(text) - len(text.lstrip())
|
||||
res = _result(text)
|
||||
if res:
|
||||
self.wrap(ln, lead, text.rfind("=") + 1, "\x1a")
|
||||
self.result(ln, res)
|
||||
elif part == "T":
|
||||
colon = _COLON.search(text).end()
|
||||
self.wrap(ln, lead, colon, "\x1a")
|
||||
nums = _nums(text, colon)
|
||||
if nums:
|
||||
self.wrap(ln, nums[0][0], nums[0][1], "\x12")
|
||||
ln["결과"] = [nums[0][2]]
|
||||
else:
|
||||
self.wrap(ln, lead, len(text.rstrip()), "\x1a")
|
||||
return ln
|
||||
|
||||
def result(self, ln: dict, res: tuple) -> None:
|
||||
s, e, v, letter = res
|
||||
if letter is not None:
|
||||
self.wrap(ln, letter, letter + 1, "\x06")
|
||||
self.wrap(ln, s, e, "\x12")
|
||||
ln["결과"].append(v)
|
||||
|
||||
def triple_line(self, ln: dict, part: str) -> bool:
|
||||
"""세 줄 — 단가 = 머리 자원(기계 · 산근 · 견적)의 그 비목 값."""
|
||||
text = ln["보임"]
|
||||
colon = _COLON.search(text).end()
|
||||
nums = _nums(text, colon)
|
||||
if not nums or not self.heads:
|
||||
return False
|
||||
price = nums[0]
|
||||
tail = text[price[1] :]
|
||||
if ("=" not in tail and tail.strip()) or tail.lstrip()[:1] == "+":
|
||||
return False
|
||||
fit = [
|
||||
h
|
||||
for h in reversed(self.heads)
|
||||
if _close(price[2], float(h["자원"]["값"].get(part) or 0))
|
||||
]
|
||||
if not fit:
|
||||
return False
|
||||
self.ctx = fit[0]
|
||||
self.ctx["씀"] = True
|
||||
one = self.ctx["자원"]
|
||||
self.link(ln, self.ctx["종류"], one)
|
||||
ln["비목"] = part
|
||||
if self.ctx["종류"] == "기계":
|
||||
ln["기계"] = True
|
||||
self.money(ln, price)
|
||||
self.triple = True
|
||||
return True
|
||||
|
||||
def triple_total(self, ln: dict) -> dict:
|
||||
one = self.ctx["자원"]
|
||||
self.link(ln, self.ctx["종류"], one)
|
||||
ln["비목"] = "T"
|
||||
text = ln["보임"]
|
||||
res = _result(text)
|
||||
if res:
|
||||
self.result(ln, res)
|
||||
else:
|
||||
m = re.search(r"[::]\s*(?:([A-Z])\s*)?(" + NUM + r")", text)
|
||||
if m:
|
||||
self.result(
|
||||
ln, (m.start(2), m.end(2), _v(m.group(2)), m.start(1) if m.group(1) else None)
|
||||
)
|
||||
money = ln["저장"] or {}
|
||||
rows = []
|
||||
for one in reversed(self.lines):
|
||||
if one["연결"] != ln["연결"] or one["비목"] not in ("L", "M", "S"):
|
||||
break
|
||||
rows.append(one)
|
||||
cols = [c for c in "LMS" if money.get(c)]
|
||||
total = sum((one["결과"] or [0])[-1] for one in rows)
|
||||
if len(cols) == 1 and money.get("T") and abs(total - money["T"]) < 1:
|
||||
for one in rows: # 계 줄 금액 칸 비목 하나 = 값 있는 세 줄 모두 그 비목으로 옮김
|
||||
if (one["결과"] or [0])[-1]:
|
||||
one["비목"] = cols[0]
|
||||
elif money:
|
||||
for one in rows: # 계 줄 금액 칸이 빈 비목 = 합에 안 든 줄(비목 없음)
|
||||
if not money.get(one["비목"]) and one["결과"] and one["결과"][-1]:
|
||||
one["비목"] = None
|
||||
self.triple = False
|
||||
return ln
|
||||
|
||||
def item(self, ln: dict, kind: str, one: dict, price: tuple) -> dict:
|
||||
self.link(ln, kind, one)
|
||||
self.names(ln, one)
|
||||
part = self.money_part(ln)
|
||||
ln["비목"] = part if part in ("L", "M", "S") else None
|
||||
self.money(ln, price)
|
||||
return ln
|
||||
|
||||
def money(self, ln: dict, price: tuple[int, int, float]) -> None:
|
||||
"""금액 줄 — 단가(`\\x03`) · 첫 식 조각의 앞 대입 값 = 변수(`\\x11`) · 끝 = 결과."""
|
||||
text = ln["보임"]
|
||||
s, e, v = price
|
||||
self.wrap(ln, s, e, "\x03")
|
||||
ln["연결단가"] = [v]
|
||||
self.var(ln, e)
|
||||
res = _result(text)
|
||||
if res and res[0] > e:
|
||||
self.result(ln, res)
|
||||
|
||||
def var(self, ln: dict, start: int) -> None:
|
||||
"""첫 식 조각(start ~ 첫 「=」)의 수 중 가장 늦게 대입한 변수 값 하나 = 변수(`\\x11` · 원파일 = 줄마다 하나) ·
|
||||
칸 나눔 꼴은 닫는 표지 자리(2 칸)가 있는 수만."""
|
||||
text = ln["보임"]
|
||||
eq = text.find("=", start)
|
||||
order = list(self.vars.values())
|
||||
best = None
|
||||
for a, b, val in _nums(text, start, eq if eq >= 0 else None):
|
||||
rank = max((i for i, x in enumerate(order) if _close(val, x)), default=-1)
|
||||
if ln["_칸"] and not self.closed(text, b):
|
||||
continue
|
||||
if rank >= 0 and (best is None or rank > best[0]):
|
||||
best = (rank, a, b, val)
|
||||
if best:
|
||||
self.wrap(ln, best[1], best[2], "")
|
||||
ln["변수"].append(best[3])
|
||||
|
||||
def subtotal(self, ln: dict) -> bool:
|
||||
"""「…소계 : 수」 — 금액 칸 계 없음 · 비목 하나(61) · 금액 칸 없음 = 비목 없음."""
|
||||
text = ln["보임"]
|
||||
m = _COLON.search(text)
|
||||
if not m or "소계" not in sx.flat(text[: m.start()]):
|
||||
return False
|
||||
money = ln["저장"] or {}
|
||||
parts = [c for c in "LMS" if money.get(c)]
|
||||
if len(parts) != 1 or (
|
||||
money.get("T") is not None and not _close(money["T"], money[parts[0]])
|
||||
):
|
||||
return False # 소계 61 = 금액 칸 비목 하나(계 칸 없음 · 있으면 같은 값)
|
||||
got = re.match(r"\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text[m.end() :])
|
||||
if not got:
|
||||
return False
|
||||
at = m.end()
|
||||
if got.group(1):
|
||||
self.wrap(ln, at + got.start(1), at + got.end(1), "\x06")
|
||||
self.wrap(ln, at + got.start(2), at + got.end(2), "\x0c")
|
||||
ln["줄참조"] = [_v(got.group(2))]
|
||||
ln["저장"] = {**money, "T": money[parts[0]]}
|
||||
self.link(ln, "소계", None)
|
||||
ln["비목"] = self.money_part(ln)
|
||||
ln["표지"] = "소계"
|
||||
self.var(ln, at + got.end(2))
|
||||
res = _result(text)
|
||||
if res and res[0] > at + got.end(2):
|
||||
self.result(ln, res)
|
||||
return True
|
||||
|
||||
def rate62(self, ln: dict) -> bool:
|
||||
"""「(A 수 + 수) × n% = 값」 · 「A 수 + 수 = 합 × n% = 값」 — 구간 합 율(62 · 합계 표지)."""
|
||||
text = ln["보임"]
|
||||
m = _GROUP62.search(text)
|
||||
if not m or not re.search(r"[×xX*]", text[m.end() :]) or not ln["저장"]:
|
||||
return False
|
||||
self.wrap(ln, m.start(1), m.end(1), "\x07")
|
||||
self.wrap(ln, m.start(2), m.end(3), "\x1a")
|
||||
ln |= {"합계": True}
|
||||
self.link(ln, "율62", None)
|
||||
ln["비목"] = self.money_part(ln)
|
||||
ln["표지"] = "율"
|
||||
for r in re.finditer(r"=\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text):
|
||||
self.result(
|
||||
ln, (r.start(2), r.end(2), _v(r.group(2)), r.start(1) if r.group(1) else None)
|
||||
)
|
||||
return True
|
||||
|
||||
def rate(self, ln: dict) -> bool:
|
||||
"""「수 × n% = 값」 + 금액 칸 — 율(58 · 첫 수 = 줄참조)."""
|
||||
text = ln["보임"]
|
||||
res = _result(text)
|
||||
if not ln["저장"] or not res:
|
||||
return False
|
||||
colon = _COLON.search(text)
|
||||
nums = _nums(text, colon.end() if colon else 0, text.rfind("="))
|
||||
seen = [v for x in self.lines for v in x["결과"] + x["줄참조"]]
|
||||
before = any(_close(nums[0][2], v) for v in seen) if nums else False
|
||||
lead = text[colon.end() if colon else 0 :].lstrip(" (")
|
||||
before = before and lead[:1].isdigit()
|
||||
if not nums or ("%" not in text[: text.rfind("=")] and not before):
|
||||
return False
|
||||
self.wrap(ln, nums[0][0], nums[0][1], "\x0c")
|
||||
ln["줄참조"] = [nums[0][2]]
|
||||
self.link(ln, "율", None)
|
||||
ln["비목"] = self.money_part(ln)
|
||||
ln["표지"] = "율"
|
||||
self.result(ln, res)
|
||||
return True
|
||||
|
||||
def plain(self, ln: dict) -> None:
|
||||
"""글 · 변수 줄 — 대입마다 「이름 = 식 = 값」 = 결과 · 그 밖 「… = 값」(식 있는 줄) = 결과."""
|
||||
text = ln["보임"]
|
||||
ln["연결"] = 0
|
||||
part = self.money_part(ln)
|
||||
ln["비목"] = part
|
||||
hits: list[re.Match] = []
|
||||
for m in _ASSIGN.finditer(text):
|
||||
if m.group(1).lower() in _UNITS and re.search(r"\d\.?\s?$", text[: m.start()]):
|
||||
continue # 「8 hr =」 = 단위
|
||||
gap = text[hits[-1].end() : m.start()] if hits else ""
|
||||
if not hits or (re.search(r"\d", gap) and not re.search(r"[×÷+\-*/(=]\s*$", gap)):
|
||||
hits.append(m)
|
||||
lead = text[: hits[0].start()].strip(" ○●·-{") if hits else ""
|
||||
if hits and (not lead or re.search(r"[::]$", lead)):
|
||||
for n, m in enumerate(hits):
|
||||
end = hits[n + 1].start() if n + 1 < len(hits) else len(text)
|
||||
seg = text[m.end() : end]
|
||||
parts = seg.split("=")
|
||||
val = re.match(r"\s*(?:[A-Z](?=\d))?(" + NUM + r")", parts[-1])
|
||||
if not val:
|
||||
continue
|
||||
at = m.end() + len(seg) - len(parts[-1])
|
||||
if len(parts) >= 2:
|
||||
res = _result(text[:end])
|
||||
if res and res[0] >= at:
|
||||
self.result(ln, res)
|
||||
self.vars.pop(m.group(1), None)
|
||||
self.vars[m.group(1)] = _v(val.group(1))
|
||||
return
|
||||
eq = text.rfind("=")
|
||||
seg = re.split(r"[=::]", text[:eq])[-1]
|
||||
if eq > 0 and re.search(r"[×÷+*]|\d\s*/|/\s*\d", seg):
|
||||
res = _result(text)
|
||||
if res:
|
||||
self.result(ln, res)
|
||||
|
||||
|
||||
# ── 책 ────────────────────────────────────────────────────────────────
|
||||
def _glyph(text: str, marks: list[tuple[int, int, str]]) -> str:
|
||||
"""보인 글 + 표지 자리 → 원파일 LineData 꼴(닫는 표지 먼저)."""
|
||||
ins: list[tuple[int, int, str]] = []
|
||||
for s, e, ch in marks:
|
||||
ins.append((s, 1, ch))
|
||||
ins.append((e, 0, ch))
|
||||
out, last = [], 0
|
||||
for at, _, ch in sorted(ins, key=lambda x: (x[0], x[1])):
|
||||
out.append(text[last:at])
|
||||
out.append(ch)
|
||||
last = at
|
||||
out.append(text[last:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def index(res: dict[str, list[dict]]) -> dict[str, Any]:
|
||||
"""자원 표 → 찾기 색인(단가 → 노임 · 자재 · 이름 → 기계 · (종류, 번) → 산근 · 견적)."""
|
||||
out: dict[str, Any] = dict(res)
|
||||
out["_값"] = {}
|
||||
for kind in ("노임", "자재"):
|
||||
for one in res.get(kind) or []:
|
||||
out["_값"].setdefault(round(one["값"]["T"], 4), []).append((kind, one))
|
||||
out["_기계"] = {}
|
||||
for one in res.get("기계") or []:
|
||||
if sx.flat(one["이름"]):
|
||||
out["_기계"].setdefault(sx.flat(one["이름"]), []).append(one)
|
||||
names = sorted(out["_기계"], key=len, reverse=True)
|
||||
out["_기계틀"] = re.compile("(?=(" + "|".join(map(re.escape, names)) + "))") if names else None
|
||||
out["_번"] = {(k, one["번"]): one for k in ("산근", "견적") for one in res.get(k) or []}
|
||||
return out
|
||||
|
||||
|
||||
def read(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
"""EST 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 표지}(`M02_San_Estx.read` 꼴 · 일위 = 호표 길)."""
|
||||
res = sx.resources(sheets)
|
||||
look = index(res)
|
||||
total = {one["번"]: one for one in res.get("산근") or []}
|
||||
out: dict[str, Any] = {"꼴": "EST", "산근": [], "일위": [], "내역": None}
|
||||
for b in blocks(sheets.get(SHEET) or []):
|
||||
tags: list[str] = []
|
||||
one = total.get(b["번"])
|
||||
m = _HEAD.match(b["머리"])
|
||||
head = {
|
||||
"코드": f"단산{b['번']}",
|
||||
"이름": one["이름"] if one else m.group(2).strip(),
|
||||
"규격": one["규격"] if one else "",
|
||||
"단위": one["단위"] if one else m.group(3).strip(),
|
||||
"저장": b["저장"] or (dict(one["값"]) if one else None),
|
||||
}
|
||||
if not one:
|
||||
tags.append(f"{sx.NOTE}총괄표에 단산{b['번']} 없음")
|
||||
elif one.get("칸"): # 총괄표 당초 / 적용 칸(797-1a)
|
||||
tags.append(f"참고 · {one['칸']}")
|
||||
work = Lines(look, tags)
|
||||
for src in b["줄"]:
|
||||
work.run(src)
|
||||
work.unused()
|
||||
work.sections(head["저장"] or {})
|
||||
lines = []
|
||||
for ln in work.lines:
|
||||
marks = ln.pop("_marks")
|
||||
ln.pop("_칸", None)
|
||||
ln["글"] = _glyph(ln["보임"], marks)
|
||||
lines.append(ln)
|
||||
out["산근"].append({**head, "번": b["번"], "엑셀행": b["행"], "줄": lines, "표지": tags})
|
||||
out["자원"] = {
|
||||
f"{kind}:{one['번']}": {k: one[k] for k in ("이름", "규격", "단위", "표")}
|
||||
| {"저장": one["값"]}
|
||||
for kind, rows in res.items()
|
||||
for one in rows
|
||||
}
|
||||
out["표지"] = [
|
||||
f"{sx.NOTE}자원 코드 · LinkIndex · 자리(Diget) 없음 — 연결뜻 = 글 · 금액 칸 · 단가표로 정함({sx.GUESS})",
|
||||
f"{sx.NOTE}자원 코드 = 단가표 이름#No(산근 = 단산 번)",
|
||||
]
|
||||
return out
|
||||
@@ -0,0 +1,77 @@
|
||||
"""STmate 산근 엑셀 → 810-1 구조 줄(PLAN 812-1 · `M02_San_Stc.read` 와 같은 꼴 · 읽기만 · 서버 단독).
|
||||
|
||||
엑셀 「단가산출근거」 숨은 G 열 = 원파일 COSTN.SMT 원문 그대로 → 줄 = `M02_San_Stc.stc_line`(풀이 =
|
||||
`M02_San_Stmate.parse_line` · 다시 짜지 않음) · 블록 머리 금액(C~F) = 원파일 저장(TAMT1 …) ·
|
||||
단가 = 목록표(노무비 · 재료비 · 경비 · 중기 · 단가산출근거 · 숨은 메타 = COSTN 칸) ·
|
||||
끝전 = 파일 투표(`_원파일파서_틀.md` 4 장 SAN_TYP 두 갈래 중 블록 계가 맞는 수가 많은 쪽 · 파일에 하나 ·
|
||||
같으면 비목버림).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
from M02_MasterTemplete import M02_San_Stc as stc
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
|
||||
RULE = {"비목버림": "1", "합버림_큰나머지": "0"} # 끝전 갈래 → SAN_TYP
|
||||
|
||||
|
||||
def _amt(head: dict) -> dict[str, float]:
|
||||
return {c: float(head.get(k) or 0) for c, k in zip("TLMS", "CDEF")}
|
||||
|
||||
|
||||
def vote(blocks: list[dict], prices: dict) -> tuple[str, dict[str, int]]:
|
||||
"""파일 끝전 — 블록마다 두 갈래로 셈해(낙찰 · 조정 뒤) 엑셀 머리 계(C)와 맞는 수 · 많은 쪽(같으면
|
||||
비목버림)."""
|
||||
got = dict.fromkeys(RULE, 0)
|
||||
for blk in blocks:
|
||||
nodes = [ln["풀이"] for ln in blk["줄"]]
|
||||
want = (blk.get("저장") or {}).get("T")
|
||||
for rule in RULE:
|
||||
try:
|
||||
total = ev.stmate_block({"코드": blk["코드"]}, nodes, prices, rule)["금액"]["T"]
|
||||
except Exception: # noqa: BLE001 — 못 셈 = 표 없음
|
||||
continue
|
||||
got[rule] += want is not None and abs(float(total) - float(want)) < 0.5
|
||||
best = max(RULE, key=lambda r: (got[r], r == "비목버림"))
|
||||
return best, got
|
||||
|
||||
|
||||
def read(sheets: dict[str, list]) -> dict[str, Any]:
|
||||
"""STmate 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 단가, 옵션, 표지}."""
|
||||
found = book.stmate_prices(sheets)
|
||||
prices = {c: {k: p.get(k) for k in ("T", "L", "M", "S", "meta")} for c, p in found.items()}
|
||||
res = {
|
||||
c: {"종류": stc.KIND.get(c[:1]), **{k: p.get(k) for k in ("이름", "규격", "단위")}}
|
||||
| {"저장": {k: p.get(k) for k in "TLMS"}}
|
||||
for c, p in found.items()
|
||||
}
|
||||
out: dict[str, Any] = {"꼴": "STmate", "산근": [], "일위": [], "자원": res, "단가": prices}
|
||||
for blk in book.stmate_blocks(sheets):
|
||||
tags: list[str] = []
|
||||
lines = [stc.stc_line(i, ln["G"], res, tags) for i, ln in enumerate(blk["몸"], 1)]
|
||||
for one, src in zip(lines, blk["몸"]):
|
||||
one["엑셀행"] = src["행"]
|
||||
me = found.get(blk["코드"]) or {}
|
||||
head = {"코드": blk["코드"], "이름": blk.get("이름") or me.get("이름")}
|
||||
head |= {"규격": me.get("규격"), "단위": me.get("단위"), "저장": _amt(blk["머리"])}
|
||||
_, tot = ev.run_stmate([ln["풀이"] for ln in lines], prices)
|
||||
body = [{k: ln.get(k) for k in ("행", "B", "C", "D", "E", "F")} for ln in blk["몸"]]
|
||||
out["산근"].append(
|
||||
{**head, "줄": lines, "줄합": tot, "표지": tags, "엑셀행": blk["행"]}
|
||||
| {
|
||||
"몸": body,
|
||||
"꼬리": blk["꼬리"],
|
||||
} # 엑셀 줄 금액 · 계 꼬리(검산 · `M02_San_Emit.stmate`)
|
||||
)
|
||||
rule, got = vote(out["산근"], prices)
|
||||
out["옵션"] = {"SAN_TYP": RULE[rule]}
|
||||
out["내역"] = None
|
||||
out["표지"] = [
|
||||
f"{sx.NOTE}일위대가 구성 · 내역은 호표 길(산근만 읽음)",
|
||||
f"{sx.NOTE}끝전 = 파일 투표 {rule}(비목버림 {got['비목버림']} · 합버림 {got['합버림_큰나머지']} 블록)",
|
||||
]
|
||||
return out
|
||||
@@ -1,23 +1,21 @@
|
||||
"""M02 불러오기 단산 블록 → 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647 · 652) — 서버 단독.
|
||||
|
||||
올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)를
|
||||
`실무자료/산근/<HP>.json` 에 둠
|
||||
(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 793-2a 내보내기
|
||||
(`M02_San_Emit` · 이음 = 1차 줄 판정) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 ·
|
||||
올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)와 그 엑셀 읽기기 둘 꼴
|
||||
(812-1 `M02_San_Xlsx` → `M02_Doc_FromNative.pack` · 칸 `엑셀`)을 `실무자료/산근/<HP>.json` 에 둠
|
||||
(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 원파일과 같은 길
|
||||
(`M02_Doc_FromNative.emit` · 원본 `엑셀` 참) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 ·
|
||||
엑셀 단가 그대로 · 경쟁사 로직 인정 — 근거 기초 제안 없음 · 809-1 · 요구 703) ·
|
||||
같은 내용 SK 가 있으면 그것(다시씀).
|
||||
블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번).
|
||||
STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해
|
||||
엑셀 계와 맞는 것.
|
||||
원파일(.estx · .stc · 810-2 `M02_Doc_FromNative`) = 같은 자리 칸 `원파일`(둘 꼴) — 있으면 원파일 우선
|
||||
(그 블록 번이 원파일에 있을 때) · 엑셀 올림 때 같은 공사 원파일이 다른 HP 에 있으면 붙임 · 원파일 올림 =
|
||||
같은 공사 엑셀 HP 에 붙임(없으면 원파일로 새 HP · `upload_native`).
|
||||
STmate 블록 끝전 = 파일 투표(`M02_San_Xlsx_Stmate.vote`).
|
||||
원파일(.estx · .stc · 810-2 `M02_Doc_FromNative`) = 같은 자리 칸 `원파일`(둘 꼴) · 올리기 = 파일 하나
|
||||
(814-1 · 요구 742-1 — 같은 공사 엑셀 · 원파일 붙이기 걷음) · 원파일 = 새 HP(단가산출표 판 격자 · 일위대가 없음 ·
|
||||
`upload_native`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -30,7 +28,6 @@ from M02_MasterTemplete import M02_San_Emit as em
|
||||
|
||||
DIRNAME = "산근"
|
||||
RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림"
|
||||
RULES = ("비목버림", "합버림_큰나머지")
|
||||
MAX_INNER = 6 # 안쪽 산근 깊이 상한
|
||||
|
||||
|
||||
@@ -60,32 +57,34 @@ def keep(folder: str | Path, key: str, name: str, data: bytes) -> str | None:
|
||||
return f"엑셀 형식 오류 — {type(e).__name__}: {str(e)[:80]}"
|
||||
if not book.kind_of(sheets):
|
||||
return None
|
||||
try:
|
||||
excel = _excel(name, sheets)
|
||||
except Exception as e: # noqa: BLE001 — 산근 읽기 실패 = 사유(올리기는 됨)
|
||||
return f"산근 시트 읽기 오류 — {type(e).__name__}: {str(e)[:80]}"
|
||||
path = _path(folder, key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
got = {"파일": name, "시트": sheets}
|
||||
native = next((n for n in _natives(folder) if fn.same(n, sheets)), None)
|
||||
atomic_write_json(path, got | ({"원파일": native} if native else {}))
|
||||
atomic_write_json(path, {"파일": name, "시트": sheets, "엑셀": excel})
|
||||
return None
|
||||
|
||||
|
||||
def _excel(name: str, sheets: dict) -> dict:
|
||||
"""산근 시트 → 원파일과 같은 둘 꼴(812-1 엑셀 읽기기 `M02_San_Xlsx` → `M02_Doc_FromNative.pack`)."""
|
||||
from M02_MasterTemplete import M02_San_Xlsx as sx
|
||||
|
||||
return fn.pack(sx.read_sheets(sheets), name)
|
||||
|
||||
|
||||
def _excel_of(path: Path, saved: dict) -> dict:
|
||||
"""저장 HP 의 엑셀 둘 꼴 — 옛 저장(시트만)은 한 번 지어 그 자리에 둠."""
|
||||
if not isinstance(saved.get("엑셀"), dict):
|
||||
saved["엑셀"] = _excel(str(saved.get("파일") or ""), saved["시트"])
|
||||
atomic_write_json(path, saved)
|
||||
return saved["엑셀"]
|
||||
|
||||
|
||||
# ── 원파일(810-2) ──────────────────────────────────────────────────────
|
||||
def _saved(folder: str | Path) -> list[tuple[Path, dict]]:
|
||||
out = []
|
||||
for path in sorted((hopyo_store._dir(folder) / DIRNAME).glob("HP-*.json")):
|
||||
try:
|
||||
out.append((path, json.loads(path.read_bytes())))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _natives(folder: str | Path) -> list[dict]:
|
||||
return [d["원파일"] for _, d in _saved(folder) if isinstance(d.get("원파일"), dict)]
|
||||
|
||||
|
||||
def upload_native(folder: str | Path, name: str, data: bytes, who: Any) -> dict[str, Any]:
|
||||
"""원파일 올림 — 같은 공사 엑셀 HP 에 원파일을 붙임(`붙음` 키들 · 첫 HP 답) · 없으면 원파일로
|
||||
새 HP(EST = 일위대가 호표 · STmate = 호표 없음 · 산근만). 같은 파일 다시 = 그 HP(`이어서`)."""
|
||||
"""원파일 올림 — 새 HP(단가산출표 판 격자 · 산근만 · 일위대가 없음 · 814-1) · 같은 파일 다시 = 그 HP(`이어서`)."""
|
||||
mark = hopyo_store.fingerprint(data)
|
||||
if same := next((r for r in hopyo_store.rows(folder) if r.get("지문") == mark), None):
|
||||
return {**same, "이어서": True, "원파일": True}
|
||||
@@ -95,18 +94,13 @@ def upload_native(folder: str | Path, name: str, data: bytes, who: Any) -> dict[
|
||||
native = fn.read(name, data)
|
||||
except fn.NativeError as e:
|
||||
raise store.StoreError(400, str(e)) from e
|
||||
joined = []
|
||||
for path, got in _saved(folder):
|
||||
if got.get("시트") and fn.same(native, got["시트"]):
|
||||
atomic_write_json(path, {**got, "원파일": native})
|
||||
joined.append(path.stem)
|
||||
rows = {r["키"]: r for r in hopyo_store.rows(folder)}
|
||||
if joined and joined[0] in rows:
|
||||
return {**rows[joined[0]], "원파일": True, "붙음": joined}
|
||||
row = hopyo_store.save(folder, name, fn.hopyo(native), who, None, mark)
|
||||
path = _path(folder, row["키"])
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, {"파일": name, "원파일": native})
|
||||
|
||||
def first(key: str) -> None: # HP 를 목록에 보이기 전에 산근 보관(814-1)
|
||||
path = _path(folder, key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, {"파일": name, "원파일": native})
|
||||
|
||||
row = hopyo_store.save(folder, name, fn.hopyo(native), who, fn.grid(native), mark, first)
|
||||
return {**row, "원파일": True}
|
||||
|
||||
|
||||
@@ -114,32 +108,12 @@ def has(folder: str | Path, key: str) -> bool:
|
||||
return _path(folder, key).is_file()
|
||||
|
||||
|
||||
def _rule(blk: dict, nodes: list, prices: dict) -> str:
|
||||
"""STmate 블록 끝전 — 엑셀 계(꼬리 · 없으면 머리)와 맞는 쪽 · 둘 다 아니면 비목버림."""
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
|
||||
tails = [t for t in blk["꼬리"] if t["A"].startswith("계") and not t["A"].startswith("계약")]
|
||||
want = (tails or [blk["머리"]])[0].get("C")
|
||||
for rule in RULES:
|
||||
try:
|
||||
got = ev.stmate_block(blk, nodes, prices, rule)["계"]["T"]
|
||||
except Exception: # noqa: BLE001 — 못 셈 = 다음 갈래
|
||||
continue
|
||||
if want is not None and abs(float(got) - float(want)) < 0.5:
|
||||
return rule
|
||||
return RULES[0]
|
||||
|
||||
|
||||
def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
|
||||
"""HP 단산 블록 하나 → `{로직, 원본}`(793-2a 꼴 · 이음 = M01 키)."""
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Emit as em
|
||||
from M02_MasterTemplete import M02_San_Est as est
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
|
||||
"""HP 단산 블록 하나 → `{로직, 원본}`(원파일 우선 · 아니면 엑셀 읽기기 같은 길 · 이음 = M01 키)."""
|
||||
hopyo_store.read(folder, key) # 없는 HP = 404
|
||||
path = _path(folder, key)
|
||||
try:
|
||||
saved = json.loads(_path(folder, key).read_bytes())
|
||||
saved = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError) as e:
|
||||
raise store.StoreError(409, RELOAD) from e
|
||||
native = saved.get("원파일")
|
||||
@@ -147,52 +121,22 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
|
||||
return fn.emit(native, int(no))
|
||||
if not saved.get("시트"):
|
||||
raise store.StoreError(404, f"없는 단산{no}")
|
||||
sheets, name = saved["시트"], str(saved.get("파일") or "")
|
||||
if book.kind_of(sheets) == "STmate":
|
||||
blk = next((b for b in book.stmate_blocks(sheets) if int(b["코드"][1:]) == int(no)), None)
|
||||
if blk is None:
|
||||
raise store.StoreError(404, f"없는 단산{no}")
|
||||
prices = book.stmate_prices(sheets)
|
||||
nodes = stm.parse_block(blk["몸"])
|
||||
return em.stmate(blk, nodes, prices, _rule(blk, nodes, prices), True, name)
|
||||
prices, total = book.est_prices(sheets)
|
||||
blk = next((b for b in book.est_blocks(sheets) if int(b["번"]) == int(no)), None)
|
||||
if blk is None or blk["번"] not in total:
|
||||
excel = _excel_of(path, saved)
|
||||
if int(no) not in fn.numbers(excel):
|
||||
raise store.StoreError(404, f"없는 단산{no}")
|
||||
return em.est(blk, est.parse_block(blk["글"], prices), total[blk["번"]], True, name)
|
||||
return fn.emit(excel, int(no))
|
||||
|
||||
|
||||
def _inner(
|
||||
source: Path, key: str, logic: dict, native: bool = False
|
||||
) -> list[tuple[dict, int | None, str | None]]:
|
||||
"""안쪽 산근 줄 `[(호표 줄, 같은 엑셀 안쪽 블록 번, 못 잇는 까닭)]` — STmate = 줄 코드가 블록 코드 ·
|
||||
EST = 줄 이름 「단산N」(N 은 총괄표에 있는 블록). 비목 하나만 부른 줄은 그 비목이 그대로(옮김 없음)일 때만."""
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
|
||||
saved = json.loads(_path(source, key).read_bytes())
|
||||
"""안쪽 산근 줄 `[(호표 줄, 같은 파일 안쪽 블록 번, 못 잇는 까닭)]` — 원파일 · 엑셀 둘 꼴 같은 길
|
||||
(`M02_Doc_FromNative.inner` · 줄 자원 코드 = 블록 코드)."""
|
||||
path = _path(source, key)
|
||||
saved = json.loads(path.read_bytes())
|
||||
if native and isinstance(saved.get("원파일"), dict):
|
||||
return fn.inner(saved["원파일"], logic)
|
||||
sheets = saved["시트"]
|
||||
stmate = book.kind_of(sheets) == "STmate"
|
||||
have = {b["코드"]: int(b["코드"][1:]) for b in book.stmate_blocks(sheets)} if stmate else {}
|
||||
total = set() if stmate else set(book.est_prices(sheets)[1])
|
||||
out = []
|
||||
for line in logic["호표"]:
|
||||
res = line.get("자원") or {}
|
||||
if res.get("갈래") != "단가산출":
|
||||
continue
|
||||
if stmate:
|
||||
n = have.get(str(res.get("코드")))
|
||||
else:
|
||||
hit = re.fullmatch(r"단산(\d+)", str(res.get("이름")))
|
||||
n = int(hit.group(1)) if hit and int(hit.group(1)) in total else None
|
||||
if n is None:
|
||||
out.append((line, None, "같은 엑셀에 안쪽 블록 없음"))
|
||||
elif res.get("옮김") or (res.get("꼴") == "EST" and res.get("열쇠비목") not in em.COMP):
|
||||
out.append((line, n, "비목이 바뀐 부름"))
|
||||
else:
|
||||
out.append((line, n, None))
|
||||
return out
|
||||
return fn.inner(_excel_of(path, saved), logic)
|
||||
|
||||
|
||||
def _link_inner(source: Path, key: str, no: int, out: dict, trail: tuple) -> None:
|
||||
|
||||
@@ -304,11 +304,15 @@ def same_part(doc: Any) -> dict | None:
|
||||
|
||||
|
||||
def unlinked(doc: dict) -> list[dict]:
|
||||
"""이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름}]`."""
|
||||
"""이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름, 까닭, 견적?}]`(까닭 = 814-1 `M02_San_Link.why` ·
|
||||
견적 = 815-1 K5 그대로 쓰는 견적 · 운임 · 수수료 줄 — 못 이음 수에서 뺌)."""
|
||||
from M02_MasterTemplete import M02_San_Link as link
|
||||
|
||||
row = row_of(doc) or {}
|
||||
ids = sd.line_ids(row)
|
||||
return [
|
||||
{"자리": ids[n], "이름": one.get("이름") or ""}
|
||||
{"자리": ids[n], "이름": one.get("이름") or "", "까닭": link.why(one)}
|
||||
| ({"견적": True} if (one.get("자원") or {}).get("갈래") == "고정" else {})
|
||||
for n, one in enumerate(doc[KEY]["로직"].get("호표") or [])
|
||||
if not one.get("이음") and not one.get("참조")
|
||||
]
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_Sangeun_Match.ts
|
||||
* 가져온 식 ↔ 우리 M01 기초 연결 판정 띠 자료(PLAN 810-4 · 요구 707 · 709 · 703) — 원문 패널 카드 위.
|
||||
* SK `가져온.원본.연결후보`(서버 `M02_Doc_Match` 가 적음) → 띠 글 · 다른 항 · 단추 하나:
|
||||
* 확정 = [우리 기초 사본으로 바꾸기] · 근접 · 없음 = 단추 없음(703) · 안 찾음 = [연결 찾기] ·
|
||||
* 바꾼 SK(`가져온보관` 있음) = 「우리 기초 사본 ○○」 + [가져온 식으로 되돌리기]
|
||||
* 화면 · DOM · fetch 없음
|
||||
* ========================================================================== */
|
||||
|
||||
export type MatchVerdict = "확정" | "근접" | "없음";
|
||||
|
||||
export interface MatchFound {
|
||||
판정?: MatchVerdict;
|
||||
키?: string;
|
||||
이름?: string;
|
||||
옵션?: Record<string, string>;
|
||||
다른?: { 항: string; 글: string }[];
|
||||
금액?: { 가져온?: Record<string, number | null>; 우리?: Record<string, number | null> };
|
||||
후보수?: number;
|
||||
가까움?: { 키?: string; 이름?: string; 다른?: { 항: string; 글: string }[] };
|
||||
}
|
||||
|
||||
export type MatchAction = "찾기" | "바꾸기" | "되돌리기" | null;
|
||||
|
||||
export interface MatchBand {
|
||||
tone: "success" | "info" | "warning";
|
||||
text: string;
|
||||
/** 다른 항 글(근접 · 없음의 가까운 후보) */
|
||||
details: string[];
|
||||
action: MatchAction;
|
||||
}
|
||||
|
||||
interface SkLike {
|
||||
바탕?: { 로직?: string } | null;
|
||||
입력값?: Record<string, unknown>;
|
||||
가져온?: { 로직?: unknown; 원본?: { 연결후보?: MatchFound } } | null;
|
||||
가져온보관?: { 가져온?: { 원본?: { 연결후보?: MatchFound } } } | null;
|
||||
}
|
||||
|
||||
const opts = (o?: Record<string, unknown>): string => {
|
||||
const list = Object.entries(o ?? {}).map(([k, v]) => `${k} ${String(v)}`);
|
||||
return list.length ? `(${list.join(" · ")})` : "(기본 옵션)";
|
||||
};
|
||||
const lines = (d?: { 항: string; 글: string }[]): string[] =>
|
||||
(d ?? []).map((x) => `${x.항} — ${x.글}`);
|
||||
|
||||
/** SK → 띠 자료 · 가져온 식도 · 바꾼 SK 도 아니면 null */
|
||||
export function matchBand(sk: SkLike | null | undefined): MatchBand | null {
|
||||
const kept = sk?.가져온보관;
|
||||
if (kept && sk?.바탕?.로직) {
|
||||
return {
|
||||
tone: "info",
|
||||
text: `우리 기초 사본 ${sk.바탕.로직} ${opts(sk.입력값)} — 원래 가져온 식은 보관`,
|
||||
details: [],
|
||||
action: "되돌리기",
|
||||
};
|
||||
}
|
||||
if (!sk?.가져온?.로직) return null;
|
||||
const f = sk.가져온.원본?.연결후보;
|
||||
if (!f?.판정) {
|
||||
return { tone: "info", text: "우리 기초와 연결을 아직 안 찾음", details: [], action: "찾기" };
|
||||
}
|
||||
const base = `${f.키 ?? ""} ${f.이름 ?? ""} ${opts(f.옵션)}`.trim();
|
||||
if (f.판정 === "확정") {
|
||||
return {
|
||||
tone: "success",
|
||||
text: `우리 기초 ${base} 와 확정 일치`,
|
||||
details: [],
|
||||
action: "바꾸기",
|
||||
};
|
||||
}
|
||||
if (f.판정 === "근접") {
|
||||
const kinds = [...new Set((f.다른 ?? []).map((x) => x.항))].join(" · ");
|
||||
return {
|
||||
tone: "warning",
|
||||
text: `근접 — ${base} · 다른 항: ${kinds}`,
|
||||
details: lines(f.다른),
|
||||
action: null,
|
||||
};
|
||||
}
|
||||
const near = f.가까움;
|
||||
return {
|
||||
tone: "info",
|
||||
text: `없음 — 우리 기초 후보 ${f.후보수 ?? 0} 개와 맞지 않음`,
|
||||
details: near ? [`가장 가까운 ${near.키 ?? ""} ${near.이름 ?? ""}`, ...lines(near.다른)] : [],
|
||||
action: null,
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,8 @@ export interface Origin {
|
||||
요약?: string;
|
||||
/** 원파일 확정 「확정 N · 표지 N · 추정 N · 확정률 N %」 */
|
||||
확정?: string;
|
||||
/** 확정 칸 이름 — 원파일 = 「원파일 확정」 · 엑셀 = 「엑셀 읽기 확정」(814-1) */
|
||||
확정이름?: string;
|
||||
}
|
||||
|
||||
const BOOKS: [RegExp, string][] = [
|
||||
@@ -111,6 +113,7 @@ export function originOf(meta: OriginMeta | null | undefined): Origin | null {
|
||||
...(sure
|
||||
? {
|
||||
확정: `확정 ${sure.확정 ?? 0} · 표지 ${sure.표지 ?? 0} · 추정 ${sure.추정 ?? 0} · 확정률 ${(Math.floor(rate * 1000) / 10).toFixed(1)} %`,
|
||||
확정이름: src.원파일 ? "원파일 확정" : "엑셀 읽기 확정",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -99,6 +99,12 @@ export function priceSym(line: MoneyLine): string | null {
|
||||
}
|
||||
|
||||
/** 금액 줄 하나만 단가로 쓰는 연결 기호(요소 · 단가산출) — {줄 차례: 기호} */
|
||||
/** 가져온 기계 줄 단가 기호(812-9 `가져온.기계`) — 못 이어 숫자 연결이어도 그 줄 기계(이름 단추 · 고르기) */
|
||||
export function machineSyms(doc: FormulaDoc): Set<string> {
|
||||
const got = (doc as { 가져온?: { 기계?: { 기호?: string }[] } }).가져온?.기계;
|
||||
return new Set((Array.isArray(got) ? got : []).map((x) => String(x?.기호 ?? "")));
|
||||
}
|
||||
|
||||
export function ownedOf(doc: FormulaDoc): Map<number, string> {
|
||||
const count = new Map<string, number>();
|
||||
const bump = (ns: string[]): void => ns.forEach((n) => count.set(n, (count.get(n) ?? 0) + 1));
|
||||
@@ -107,12 +113,13 @@ export function ownedOf(doc: FormulaDoc): Map<number, string> {
|
||||
if (doc.결과) bump([doc.결과]);
|
||||
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
|
||||
const lines = new Set((doc.식 ?? []).map((l) => String(l.기호 ?? "")));
|
||||
const mach = machineSyms(doc);
|
||||
const out = new Map<number, string>();
|
||||
(doc.금액 ?? []).forEach((l, i) => {
|
||||
const s = priceSym(l);
|
||||
const kind = s ? vars.get(s)?.연결?.갈래 : undefined;
|
||||
if (s && (kind === "요소" || kind === "단가산출") && count.get(s) === 1 && !lines.has(s))
|
||||
out.set(i, s);
|
||||
const mine = kind === "요소" || kind === "단가산출" || (!!s && mach.has(s));
|
||||
if (s && mine && count.get(s) === 1 && !lines.has(s)) out.set(i, s);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -137,6 +144,7 @@ export function kindOf(
|
||||
const lb = link.키 ? String(link.키).startsWith("LB") : line.비목 === "노무비"; // 고르기 전 = 비목으로
|
||||
return lb ? "인력" : "재료";
|
||||
}
|
||||
if (s && machineSyms(doc).has(s)) return "기계"; // 못 이은 가져온 기계 줄(812-9)
|
||||
return "율";
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,11 @@ export function repick(doc: FormulaDoc, i: number, id: string, kind: MoneyKind,
|
||||
const now = linkOf(kind, p);
|
||||
if (now.갈래 === "요소" && was.지역) now.지역 = was.지역; // 지역 옵션 이음 그대로
|
||||
if (now.갈래 === "단가산출" && was.키 === now.키) now.입력 = { ...was.입력, ...now.입력 }; // 같은 로직 = 다른 넘길 입력 그대로
|
||||
// 가져온 기계 줄(812-9) — 엑셀 값 기억 그대로(엑셀 단가 기준 금액 안 바뀜 · 못 이은 기계 = 숫자 값 · 묶음)
|
||||
const kept = (was as { 엑셀?: unknown }).엑셀 ?? (was.갈래 === "숫자" ? was.값 : undefined);
|
||||
if (kind === "기계" && kept !== undefined) (now as { 엑셀?: unknown }).엑셀 = kept;
|
||||
const cost = (line as { 비목?: string }).비목 ?? "";
|
||||
if (kind === "기계" && ["노무비", "재료비", "경비"].includes(cost)) now.칸 = cost; // 비목 한 칸 줄 = 그 칸
|
||||
v.연결 = now;
|
||||
v.이름 = p.이름;
|
||||
line.이름 = p.이름;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,136 @@
|
||||
# 불러오기 방향 장부 (요구 743 ~) — 결정만 쌓음 · 코드는 한 번에
|
||||
|
||||
## 0. 기준(요구 745)
|
||||
|
||||
- 불러오기의 목적 = 우리 단가산출 기초를 만드는 것이 아님. **경쟁사 단가산출을 우리 인력 · 자재 · 기계 · 다른 단가산출 정보로 다시 만들어 내는 것**(우리 기초와 같은 꼴로).
|
||||
- 그러므로 잣대 = 「우리 특정 기초와 같은가」가 아니라 **「우리 정보로 만들어 낼 수 있는가」** = 블록의 모든 줄이 우리 요소(노임 · 자재 · 기계 · 유가 · 환율 · 형제 단가산출 · 요율표)에 이어져 [우리 단가로] 셈이 되는가.
|
||||
- 연결 찾기(우리 기초와 맞대기)는 보조 — 확정이면 [바꾸기]로 우리 기초 사본을 쓸 수 있을 뿐, 「없음」이 흠은 아님.
|
||||
- 그래서 앞으로 재는 수 = 요소 이음률(지금 인력 100 · 기계 90.8 · 재료 72.0 · 유가 0 · 형제 100 %) · [우리 단가로] 전부 우리 값(원파일 71.9 · 엑셀 74.6 %) · 못 이은 줄의 까닭별 수.
|
||||
|
||||
기준 = 812-12 정합성 보고(`_보고_812-12_불러오기정합성.md`) · 아티팩트 네 묶음(정합성 · 연결 · 흠 16 · 정할 것 4). 줄마다 상태 = 결정 · 협의 중 · 조사 필요.
|
||||
|
||||
## A. 올리기 · 표현
|
||||
|
||||
| # | 항목 | 방향 | 상태 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| A1 | 올리는 파일 | 하나만(엑셀 또는 경쟁사 확장자) · 「같은 공사 원파일 우선 붙이기」 걷음 | 결정 | 요구 742-1 · 743-1 |
|
||||
| A2 | 확장자 파일(.estx · .STC)의 표현 | 단가산출표(산출근거 머리 목록) + 산출근거만 · 일위대가 제외 | 결정 | 요구 743-2 · 흠 5 |
|
||||
| A3 | 엑셀 단가산출 이름의 규격 · 단위 꼬리 | 보이는 이름은 그대로 · 우리 기초 연결 때만 꼬리 뗀 이름으로 맞댐 | 결정 | 요구 743-3 · 흠 9 |
|
||||
|
||||
## B. 읽기 흠(심각)
|
||||
|
||||
| # | 항목 | 방향 | 상태 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| B1 | STmate 기계 비목 세 줄(노 무 비 · 재 료 비 · 경 비) | 기계 한 줄로 묶음 · 조합이면 묶은 뒤 기계마다 그룹 · 금액 그대로 | 결정 | 요구 744 · 흠 1 · 285 블록 |
|
||||
| B2 | EST 덤프 「노무비 · 재료비 · 경비」 세 줄 + 조합 엇갈림 | 세 줄 묶은 뒤 조합 가름 | 결정 | 요구 744 · 흠 2 · 83 블록 |
|
||||
| B3 | 시간당 경비 계산식이 안 붙는 기계 26 종(대형 브레이커 · 부착용 집게 · 자동덮개시설 · 진동파일 해머 · 로더(타이어) 1.72 …) | 조사(요구 746) = 품셈 원문에 다 있음(손료 산정표 8-3-1 · 8-5-1 에 그 기계 · 8-4-8 주⑤ 부착 장비 조합 셈법) · 우리 기계표 630 대 모두 자료 있음 · 빠진 것 = 시간당 경비 계산식(GC000267~276)의 「기계 목록」이 246 대만 담음 → 384 대(집재장비 · 스크린 · 엔진 · 준설선 · 크러셔 · 부착 장비 …)가 어느 식에도 없음 · 26 종은 그중 경쟁사 파일에 쓰인 것 → 안: 계산식 기계 목록을 기계표 전체로(자료에 따라 식 가족 자동 — 연료 · 조종원 있음 = 기계경비 식 · 없음 = 손료 식) · 이름으로 이음 · 굴착기 + 브레이커 · + 집게는 우리 조합 식(GC000270 · 271) | 결정 — 원문에 있는데 빠진 기계만 계산식 목록에 더함(지어 넣는 자료 0) | 요구 744 · 745 · 746 · 747 · 흠 4 |
|
||||
| B4 | M02 단가산출 목록 65~75 초 | 나중에 화면 속도와 한 번에 | 보류(요구 749) | 흠 3(불러오기 밖 · 화면) |
|
||||
|
||||
## C. 연결 찾기
|
||||
|
||||
| # | 항목 | 방향 | 상태 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| C1 | 공구손료 율의 비목(실무 재료비 ↔ 우리 경비) | 값(참고) · 확정 · [바꾸기] 하면 우리 비목(경비)으로 바꿈 | 결정 | 요구 744 · 정할 것 1 |
|
||||
| C2 | 「없음」 블록의 처리 | 기준 0 으로 봄 — 「품셈 밖」 표지 안 함 · 우리 기초와 맞추지 않음 · 그 블록의 줄이 우리 요소로 다 이어지면 「만들어짐」 · 못 이은 줄만 까닭(자재 없음 · 운임표 없음 · 지자체 품 …)을 표시 · 조합 묶음은 안의 참조로 | 결정 | 요구 745 |
|
||||
| C3 | 후보 고르기(이름 · 절 근거 먼저 · 덧말 뺀 이름 · 엉뚱한 근접 막기) | — | 협의 중 | 흠 6 · 7 · 8 |
|
||||
| C4-유가 | 유가 · 전력 29 줄이 전부 「후보 여럿」 | 조사(요구 747) = 이름은 다 맞음(경유 → 자동차용경유 · 등유 → 실내등유 · 전력 → 전력량요금) · 우리 표가 유종마다 전국평균 + 전국 + 16 지역 = 18 줄 · 전력은 계약종별 2 줄이라 「하나일 때만 잇는」 규칙에 늘 걸림 → 안: 유가 = 전국평균 줄에 잇고 지역은 문서 지역 옵션으로(우리 기초와 같은 꼴) · 전력 = 우리 기초가 쓰는 기본 계약종별에 잇고 계약종별 옵션 · 엑셀 값은 검산용으로 기억 | 결정(요구 748) — 유가 = 전국(우리 기초 기본값과 같은 「전국평균」 줄 · 지역은 옵션 · 사용자가 나중에 바꿈) · 전력 = 「일반용(갑)Ⅰ 저압」 기본(공사용 전력 · 주택용 아님) · 계약종별 옵션 · 엑셀 값은 검산용 | 흠 10 |
|
||||
| C4-재료 | 재료 후보 여럿(EST 원파일 자재 쓰임 1,553 중 289 · 53 종) | 조사(요구 748) = 53 종 가운데 50 종(쓰임 283)은 후보 다섯 안에 **이름 · 규격이 정확히 같은 줄**이 있음(유리알 1호 고휘도 · 합판(내수) 12t · 철못 N 25 · 구역화물 8.0톤 30km · 급수호스 2"×10m · 잔디 0.3 …) — 우리 자재표가 이름 뒤에 「, 서울」 같은 지역 꼬리를 달거나 색 · 규격이 다른 형제를 같이 내놓아 「하나」가 못 됨 · 나머지 3 종(고무테이프 · 초류종자 · PVC D=100)만 진짜 없음 → 안: 지역 꼬리 뗀 뒤 이름 · 규격이 정확히 같은 줄이 있으면 그것으로 잇고 지역은 옵션(유가와 같은 꼴) · 정확 일치가 없을 때만 「사람 확인」 | 결정(요구 749) | 흠 10 |
|
||||
|
||||
## D. 가벼움 · 도구
|
||||
|
||||
| # | 항목 | 방향 | 상태 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 조합 첫 줄 기계 칩 글 · 소계 검산 표지 · 찾기 없는 기계 줄 7 · 올린 직후 409 · 엑셀 블록 「원파일 확정」 글 · 전수 도우미 str() | 코드 한 번에 할 때 같이 | 결정(묶음) | 흠 11 ~ 16 |
|
||||
|
||||
## E. 수정 사항 정리(코드 한 번에 할 때의 목록 · 요구 749)
|
||||
|
||||
| # | 고칠 것 | 근거 |
|
||||
|---|---|---|
|
||||
| 1 | 올리기 = 파일 하나(엑셀 또는 확장자) · 「같은 공사 원파일 우선 붙이기」 걷음 | A1 |
|
||||
| 2 | 확장자 파일(.estx · .STC) = 산출근거 머리로 단가산출표 판 + 산출근거 · 일위대가 안 만듦 | A2 |
|
||||
| 3 | 연결 찾기 · 요소 잇기 때 엑셀 이름의 규격 · 단위 꼬리 뗀 이름으로 맞댐(보이는 이름 그대로) | A3 |
|
||||
| 4 | STmate X 코드 L · M · S 세 줄 · EST 「〇〇 노무비 · 재료비 · 경비」 세 줄 = 기계 한 줄로 묶음 → 조합이면 그 뒤 기계마다 그룹 | B1 · B2 |
|
||||
| 5 | 시간당 경비 계산식(GC000267~276)의 기계 목록에 원문에 있는데 빠진 기계 더함(자료에 따라 기계경비 식 / 손료 식) · 조합(굴착기 + 브레이커 · + 집게)은 우리 조합 식 | B3 |
|
||||
| 6 | 공구손료 율의 비목 차이 = 값(참고) · [바꾸기] 때 우리 비목 | C1 |
|
||||
| 7 | 「없음」 = 흠 아님 · 「품셈 밖」 표지 없음 · 못 이은 줄만 까닭 표시 · 조합 묶음은 안의 참조로 · 잣대 = 요소 이음률 · [우리 단가로] | C2 · 기준 0 |
|
||||
| 8 | 유가 = 전국평균 줄 + 지역 옵션 · 전력 = 일반용(갑)Ⅰ 저압 + 계약종별 옵션 | C4-유가 |
|
||||
| 9 | 재료 = 지역 꼬리 뗀 이름 · 규격 정확 일치면 잇기 + 지역 옵션 · 없을 때만 사람 확인 | C4-재료 |
|
||||
| 10 | 가벼움 묶음 — 조합 첫 줄 칩 글 · 소계 검산 표지 · 찾기 없는 기계 줄 7 · 올린 직후 409 · 엑셀 블록 「원파일 확정」 글 · 전수 도우미 str() | D1 |
|
||||
| 뒤 | 화면 속도(목록 70 초) · 후보 고르기(흠 6 · 7 · 8) | B4 보류 · C3 협의 중 |
|
||||
|
||||
## F. 예상 정합성(요구 749 · 어림 — 812-12 수치에 결정 사항을 얹어 셈)
|
||||
|
||||
| 잣대 | 지금 | 뒤(어림) | 셈 근거 |
|
||||
|---|---|---|---|
|
||||
| 금액 · 줄 읽기 · 차림 왕복 | 100 % | 100 % | 그대로 |
|
||||
| 좌측 차림 조합 엇갈림 | 83 블록 흠 | 0 | #4 |
|
||||
| 인력 · 형제 단가산출 이음 | 100 % | 100 % | 그대로 |
|
||||
| 기계 이음 | 90.8 %(5,699 / 6,279) | 96 ~ 99 % | #5 로 349 줄 + · 별칭 승인 65 · 후보 여럿 88 은 사용자 고름 · 표에 없음 71 남음 |
|
||||
| 재료 이음 | 72.0 %(1,466 / 2,036) | 86 ~ 98 % | #9 — EST 확인분 283 줄 확실(86 %) · 후보 여럿 556 줄 대부분 같은 꼴이면 98 % |
|
||||
| 유가 이음 | 0 %(0 / 29) | 100 % | #8 |
|
||||
| [우리 단가로] 블록 전부 우리 값(= 우리 정보로 만들어짐) | 원파일 71.9 % · 엑셀 74.6 % | 88 ~ 93 % | 0 원 줄 252 + 177 (#4 · #5) · 재료 · 유가(#8 · #9) 풀림 · 남는 것 = 우리 자재 값 0(88 줄) · 표에 없는 기계 71 · 별칭 대기 · 견적 운임 |
|
||||
| 연결 찾기 확정(보조) | 15.3 %(339) | 25 ~ 30 % | #4 로 「없음」 중 기계 세 줄 탓 24 %(어림) 풀림 · #6 공구손료 · #3 꼬리 18 · 「없음」 818 중 품셈 밖 38 % 는 그대로 |
|
||||
|
||||
## G. 814-1 뒤 협의(요구 753 · 754)
|
||||
|
||||
| # | 항목 | 방향 | 상태 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| G1 | 기초 찾기(연결 찾기) 전체 | **걷음** — 요소를 조합해 만들기로 했고 값 업데이트도 되므로 후보 · [우리 기초 사본으로 바꾸기]까지 의미 없음 · 찾는 데 자원만 듦 → 코드 한 번에 할 때 판정 띠 · 바꾸기 · 되돌리기 · 연결후보 칸 · 전수 도우미를 걷음(810-4 · 812-2 · 812-4 · 812-11 몫) | 결정 | 요구 762-1 · 763-1 |
|
||||
| G2 | 재료 후보 여럿 252 줄(105 종) | ① 표기만 다른 것(결속선 · 유로폼 패널 · 낙석방지책) = 표기 맞추기 넓힘 ② 규격이 비어 못 정하는 것(접착제 · 요소 · 각재 외송) = 사용자가 고르면 별칭으로 기억(기계와 같은 길) ③ 자재표에 없음(금강사 등 14 줄) = 자재표 보강 후보 목록 · G1 보다 먼저 | 협의 중(브레인 안) | 요구 753 |
|
||||
| G3 | 재료 지역 | 지역 옵션 칸을 새로 만들지 않음 — 우리 자재표가 지역별 줄이라 재료 줄을 눌러 다른 지역 줄로 바꾸면 됨(지금 다시 고르기) · 기본 = 우리 기초와 같은 서울 줄 | 결정 | 요구 754 |
|
||||
| G4 | EST 원파일 자재 = 코드로 잇기 | 조사(요구 755) = 우리 자재표 30,711 줄 = 다산소프트 EST 2026-07 main.estx 자재 DB(출처 칸에 EST 코드 M… 23,977) + 품셈재료 119 · EST 원파일 여섯의 자재 자원 24,219 가운데 23,981(99 %)이 그 코드와 같음 · 안 맞는 238 = 그 공사가 직접 넣은 자재(관급 레미콘 · 사용자 추가 M0000000xxx) · 지금 엔진은 코드를 안 쓰고 이름으로만 이음 → 안: EST 원파일 자재는 **코드 우선**(출처 코드 ↔ `자재:M…`) · 코드 없는 238 만 이름 · 못 찾으면 「자재 없음 · 엑셀 값」 · 엑셀(EST 내보내기 — 코드 없음) · STC(STmate 코드 다름)는 이름 규칙 G2 ①②③ · 814-1 뒤 재료 못 이음 252 = EST 10 · 엑셀 162 · STC 80 | 협의 중(브레인 안) | 요구 755 |
|
||||
| G5 | 경쟁사 두 DB 재료를 맞대어 우리 자재 줄에 서로의 연결 정보를 둠(요구 757-2) | 조사 = ① EST: 전역 코드(M…) 있음 · 우리 표 출처 칸에 23,977 개 이미 보관 · 원파일 자재 99 % 일치 → 코드로 바로 ② STmate: 프로젝트 파일(COSTN)의 M 코드는 **공사마다 새로 매기는 번호**(일곱 파일에서 같은 코드 · 다른 품목 60 대 같은 품목 30) · 전역 코드 사전(JDCODE_M 57,580 · 17 자)은 암호화 · 프로젝트 파일에 안 실림(NAME · SIZE · UNIT · BQCODE = 물가지 연월뿐) → STmate 쪽 연결 정보 = 코드가 아니라 **이름 · 규격 글(별칭)** ③ 일곱 STC 의 자재 228 품목을 우리 표와 글로 맞대면 이름 · 규격 다 같음 24 · 이름 같음 규격 다름 52 · 이름 없음 152 → 자동 10 % · 나머지는 사람이 한 번 이어 줌 ④ EST 출력 엑셀 못 이음 96 줄 = 이름 없음 58 · 이름 있음 규격 다름 38(북부청임도 43 · 북부청사방 31 = 옛 판 품목명 「P V C PIPE」 「유로폼(패널)」 「와이어매쉬」 · 공사가 직접 넣은 품목) → 안: 우리 자재 줄에 「연결」 칸 = EST 코드(있는 것) + 별칭 글 목록(EST 옛 이름 · STmate 이름 · 규격) · 같은 품목이면 새 줄 안 만들고 별칭만 더함(중복 최소) · 우리에 없는 품목만 새 줄(출처 표시) · 쌓는 길 = 불러올 때 못 이은 줄을 사람이 한 번 이으면 별칭으로 기억(별칭 장부 · 승인) · 실제 쓰인 것부터 | 협의 중(브레인 안) | 요구 757 |
|
||||
| G6 | 공사가 직접 넣은 품목(우리 표에 없는 자재 · 기계) | 그대로 유지해 단가산출 · 일위대가에 넣음(이름 · 규격 · 단위 · 엑셀 값) → 만들어진 M02 단가산출 · 일위대가에서 바꾸도록 유도: 그 줄에 「우리 자재 아님」 표시 · 문서 위에 「우리 정보로 안 이어진 줄 n」 띠(누르면 그 줄로) · 줄 이름 단추 = 찾아 넣기로 우리 자재 고름 → 값이 우리 값 · 고른 짝은 별칭으로 기억 · 안 바꾸면 엑셀 값 그대로 쓰되 [단가 갱신] 대상 아님을 표시 | 미룸(요구 759 — 끝내 안 이어질 때 쓰는 길) | 요구 758 · 759 |
|
||||
|
||||
## H. 814-1 뒤 수정 후보 정리(요구 759 · 사용자 확인 전)
|
||||
|
||||
| # | 고칠 것 | 상태 |
|
||||
|---|---|---|
|
||||
| H1 | EST 원파일 자재 = 우리 자재표의 참조 코드(출처 칸 EST 코드)로 바로 잇기 · 코드 없는 것만 이름 | 협의 중(G4) |
|
||||
| H2 | 우리 자재 줄에 「연결」 칸 = EST 코드 + 별칭 글 목록(EST 옛 이름 · STmate 이름 · 규격) · 같은 품목이면 새 줄 없이 별칭만 · 엑셀 · STmate 는 별칭 글로 잇기 | 협의 중(G5) |
|
||||
| H3 | 재료 표기 맞추기 넓힘(결속선(철선) ↔ 보통철선(결속선) · 유로폼 패널 · 낙석방지책 규격 표기) | 협의 중(G2 ①) |
|
||||
| H4 | 못 이은 재료를 사용자가 한 번 고르면 별칭으로 기억(기계와 같은 길 · 승인) | 협의 중(G2 ②) |
|
||||
| — | 재료 지역 = 옵션 칸 안 만듦 · 줄 바꾸기로 | 결정(G3 · 코드 없음) |
|
||||
| 미룸 | 공사 추가 품목 유도 띠(G6) · 화면 속도(B4 — 요구 761 보류) | 미룸 |
|
||||
| 협의 | 후보 고르기(G1) · 자재표 보강(J 장) | 협의 중 |
|
||||
|
||||
## I. H1 ~ H4 + 후보 고르기(G1)까지 했을 때 예상(요구 760 · 어림 — 812-12 표본 쉰 · 서른 · 814-1 수치 바탕)
|
||||
|
||||
| 잣대 | 814-1 뒤(지금) | H1 ~ H4 뒤 | + 후보 고르기 |
|
||||
|---|---|---|---|
|
||||
| 재료 이음 | 87.6 % | 바로 90 % 안팎 · 별칭이 쌓이면 96 ~ 98 % | 같음(영향 없음) |
|
||||
| 블록이 우리 정보로 만들어짐 | 원파일 85.5 % · 엑셀 79.7 % | 바로 87 · 82 % 안팎 · 별칭 쌓이면 93 ~ 95 · 90 % | 같음(영향 없음) |
|
||||
| 연결 「확정」 수(원파일 2,220) | 463(21 %) | 같음 | 550 ~ 650(25 ~ 29 %) |
|
||||
| 「확정」 판정이 맞는 비율 | 100 %(무작위 30 거짓 0) | 같음 | 100 % 유지(줄 구성이 같아야 확정) |
|
||||
| 「근접」 판정이 맞는 비율 | 37 % 안팎(서른 중 맞는 근접 11) | 같음 | 80 ~ 90 %(엉뚱한 근접 · 틀린 후보 빠짐) |
|
||||
| 「없음」 판정이 맞는 비율 | 50 % 안팎(쉰 중 맞는 없음 19 · 세 줄 탓 12 는 814-1 로 풀림) | 같음 | 70 ~ 80 %(이름 비슷한 기초를 후보로 냄) |
|
||||
| 근접 · 없음 수 | 784 · 761 | 같음 | 근접 450 ~ 550 · 없음 850 ~ 950(엉뚱한 근접이 없음으로 옮겨 감) |
|
||||
|
||||
## J. 우리 자재표 보강까지 한다면(요구 761 · 어림)
|
||||
|
||||
- 보강 대상(지금 스물네 파일 기준) = ① 우리 표에 줄은 있는데 값 칸 다섯이 모두 빈 것 — 표 전체 464 줄 · 쓰인 것 102 줄(급수호스 · 공기주입기 · 공기호스 · 수직시험관 · 시스템동바리 · Air Hammer · Button Bit …) = EST DB 자체에 값이 없는 품목 ② 우리 표에 없는 품목 — 못 이은 재료 252 줄 = 105 종(이름 없음 약 130 줄 · 이름 있음 규격 다름 약 122 줄 — 뒤쪽은 H2 ~ H4 별칭으로 먼저 풀림)
|
||||
- 정할 것 = **보강 값의 출처**: 경쟁사 파일에 적힌 값(그 공사 때 값 · [단가 갱신] 안 됨) / 물가지 · 나라장터에서 찾아 넣음(사람 작업 · 갱신 됨)
|
||||
|
||||
| 잣대 | 814-1 뒤 | H1 ~ H4 + 별칭 쌓임 | + 자재표 보강 |
|
||||
|---|---|---|---|
|
||||
| 재료 이음 | 87.6 % | 96 ~ 98 % | 98 ~ 99 % |
|
||||
| 재료 0 원 줄 | 102 | 102 | 0 가까이 |
|
||||
| 블록이 우리 정보로 만들어짐 | 원파일 85.5 % · 엑셀 79.7 % | 93 ~ 95 % · 90 % | 95 ~ 97 % · 92 ~ 95 % |
|
||||
| 그래도 남는 것 | — | — | 기계 후보 여럿 88 · 별칭 대기 65(사용자 고름) · 기계 표에 없음 53 · 견적 운임 59 · 새 공사가 들고 오는 새 품목 |
|
||||
| G7 | 견적 정보(협력사 견적 · 구역화물 운임 · 수수료 등 프로젝트 안에서 지정하는 금액) | 단가산출로 만들 때 그대로 보임(이름 · 값 · 「견적」 표시) · 못 이음 · 흠으로 세지 않음 · 우리 정보로 안 바꾸고 프로젝트에 쓰면 그 가격 · **우리 자재가 있는 품목이면 견적 값 대신 우리 DB 값**(견적 값은 검산용) · 견적 지정 기능은 나중에 프로젝트 안에서 따로 · 못 찾는 품목도 나중에 같은 방식(보류) | 결정 | 요구 762-3 · 763-3 |
|
||||
| G8 | 별칭 승인 리스트 지우기 | 실행(11-11) — 개인 층 `storage/3/15` 맞춤 38 줄 · `storage/1/10` 맞춤 5 줄 비움 · 백업 `tmp/backup_762_별칭후보/` · 마스터 기계 별칭 다섯 · 회사 1 공사 내역 맞춤 10 줄은 둠 → M01 별칭 장부 「모인 후보」에 공사 내역 맞춤 10 줄이 아직 보임(지울지 사용자 확인) | 결정 · 실행 | 요구 762-2 · 763-2 |
|
||||
| G9 | 별칭이 웹앱에 들어가는지 · 수기 입력이 반영되는지(요구 763 추가 검토) | 조사 = ① 승인하면 마스터 층 `resources/master_template/실무자료/맞춤.json` 에 들어가고 M01 별칭 장부 「마스터 장부」 탭에 보임(M02 별칭 장부 부품 그대로) ② 수기 = 그 탭에서 있는 줄의 갈래 · 짝을 고치고 [저장](서버가 짝 다시 검사) · [삭제] 됨 · **새 별칭 줄을 손으로 만드는 단추는 없음**(줄은 불러오기 확인에서만 생김) · 기계 짝을 고르는 칸은 확인 못 함(부품이 로직 · 자재 짝 위주) ③ 반영 = 기계: 불러올 때 마스터 + 자기 층 장부를 먼저 봄(812-9 · 반영됨) · 재료: 일위대가 호표 줄에서만 읽음 — **단가산출 불러오기의 재료는 아직 장부를 안 읽음**(H4 몫) ④ 반영 시점 = 다음 불러오기부터(이미 만든 단가산출은 안 바뀜) | 조사 끝 | 요구 763 |
|
||||
|
||||
## K. 다음 작업 리스트(요구 764 반영 · 사용자 「진행」 전)
|
||||
|
||||
- 별칭 기준(요구 764) = **한 열로만** — 줄마다 「별칭」 칸 하나 · 콤마로 여러 개 · 손으로 입력(M01 단가산출 기초 목록의 「별칭」 칸과 같은 꼴 · 요소 표 인력 · 기계 · 자재에는 지금 그 칸이 없음).
|
||||
|
||||
| # | 작업 | 근거 |
|
||||
|---|---|---|
|
||||
| K1 | EST 원파일 자재 = 자재표 참조 코드(출처 칸 EST 코드)로 잇기 · 코드 없는 것만 이름 | G4 · H1 |
|
||||
| K2 | M01 요소 표(자재 · 기계 · 인력)에 「별칭」 한 열 — 콤마로 여러 개 · 손 입력 · 불러오기(단가산출 · 일위대가 · 재료 · 기계)가 이름보다 먼저 이 열을 봄 · 마스터 기계 별칭 다섯을 이 열로 옮김 | 요구 764 · G5 · G9 · H2 · H4 |
|
||||
| K3 | 재료 표기 맞추기 넓힘(결속선 · 유로폼 패널 · 낙석방지책 규격 표기) | G2 ① · H3 |
|
||||
| K4 | 기초 찾기(연결 찾기) 전체 걷음 — 판정 띠 · 바꾸기 · 되돌리기 · 연결후보 칸 · 전수 도우미 | G1 |
|
||||
| K5 | 견적 줄 = 「견적」 표시로 그대로 보임 · 우리 자재가 있으면 우리 값 · 못 이음 수에서 뺌 | G7 |
|
||||
| 확인 | 옛 별칭 장부(층 장부 · M01 승인 화면 · 모인 후보)를 K2 로 갈음해 걷을지 · 공사 내역 맞춤 10 줄 지울지 | 요구 764 · G8 |
|
||||
| 미룸 | 공사 추가 품목 유도 띠(G6) · 화면 속도(B4) · 자재표 보강(J · 값 출처 미정) | — |
|
||||
@@ -0,0 +1,185 @@
|
||||
# 812-12 단가산출 불러오기 정합성 — 전수 보고(요구 738-5)
|
||||
|
||||
- 대상 = 원파일 EST 6 · STC 7(산근 2,220 블록) + 실무 엑셀 11(1,021 블록) · 812-1 · 812-2 · 812-4 · 812-9 · 812-11 · 813-1 · 813-2 반영 판
|
||||
- 수치 = 전수로 잰 것 · 「어림」 = 무작위 쉰 · 서른을 사람 눈으로 가른 비율
|
||||
- 자세한 표 = `_목록_812-12_정합성.xlsx`(시트 금액 · 줄 읽기 · 요소 이음 · 우리 단가 · 차림 · 연결 판정 · 없음 쉰 · 근접 서른 · 흠)
|
||||
- 코드 · 마스터 · storage 고침 없음 · 화면에서 만든 SK 열셋 · 올린 파일 모두 지움
|
||||
|
||||
## 요약
|
||||
|
||||
| 항목 | 수 | 비율 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 1 금액(엑셀 단가 그대로 셈 = 원본) | 원파일 2,220 / 2,220 · 엑셀 1,021 / 1,021 | 100 % · 100 % | 맞음 |
|
||||
| 2 줄 읽기 — 못 읽음 표지 | 0 / 78,308 줄 | 0 % | 맞음 |
|
||||
| 2 엑셀 ↔ 원파일 실제 짝(거창 · STC 다섯) | 블록 264 · 줄 10,744 다름 0 | 100 % | 맞음 |
|
||||
| 2 기계 비목 세 줄(노 · 재 · 경 따로) | STC 285 / 285 기계 블록 · 엑셀 277 · EST 155 | STC 100 % | **흠** |
|
||||
| 3 인력 이음 | 5,089 / 5,089 줄 | 100 % | 맞음 |
|
||||
| 3 기계 이음(이어짐 · 별칭 · 조합 가름) | 5,699 / 6,279 줄 | 90.8 % | 보통 |
|
||||
| 3 기계 — 찾았으나 시간당 경비 이음 없음 | 349 줄 · 26 종 | 5.6 % | **흠** |
|
||||
| 3 재료 이음 | 1,466 / 2,036 줄 | 72.0 % | 보통 |
|
||||
| 3 유가 이음 | 0 / 29 줄(모두 후보 여럿) | 0 % | 흠 |
|
||||
| 3 형제 단가산출 이음 | 402 / 402 줄 | 100 % | 맞음 |
|
||||
| 4 [우리 단가로] 모든 줄 우리 값 | 원파일 1,596 / 2,220 · 엑셀 762 / 1,021 | 71.9 % · 74.6 % | 보통 |
|
||||
| 4 [우리 단가로] 0 원 줄 있는 블록 | 원파일 236 · 엑셀 47 | 10.6 % · 4.6 % | **흠** |
|
||||
| 5 차림 왕복(문서 → 줄 → 문서) · 거울 | 3,241 / 3,241 | 100 % | 맞음 |
|
||||
| 5 조합 기계 항목 엇갈림(비목 세 줄 + 조합) | 83 블록 | 2.6 % | 흠 |
|
||||
| 6 연결 판정(원파일) 확정 · 근접 · 없음 · 기계 작업 | 339 · 865 · 818 · 198 | 15.3 · 39.0 · 36.8 · 8.9 % | — |
|
||||
| 6 연결 판정(엑셀) 확정 · 근접 · 없음 · 기계 작업 | 205 · 260 · 472 · 84 | 20.1 · 25.5 · 46.2 · 8.2 % | — |
|
||||
| 6 「없음」 — 우리 기초 있는데 못 찾음 | 쉰 중 31 | 62 %(어림) | **흠** |
|
||||
| 6 「근접」 — 연결 찾기가 더 붙일 수 있음 | 서른 중 15 | 50 %(어림) | 흠 |
|
||||
| 7 화면 블록 열 개 금액 = 원본 · 콘솔 오류 | 10 / 10 · 0 | 100 % | 맞음 |
|
||||
| 흠 | 심각 4 · 보통 7 · 가벼움 5 | 16 | — |
|
||||
|
||||
## 1 금액
|
||||
|
||||
| 원천 | 블록 | 셈 = 원본 | 다름 | 오류 |
|
||||
|---|---|---|---|---|
|
||||
| EST 원파일 6 | 1,849 | 1,849 | 0 | 0 |
|
||||
| STC 원파일 7 | 371 | 371 | 0 | 0 |
|
||||
| 실무 엑셀 11 | 1,021 | 1,021 | 0 | 0 |
|
||||
|
||||
- 검산 줄 틀림 3 줄 — 표지 있음 2(main 1308 · 원파일 자체 엇갈림) · 표지 없음 1(북부청임도 33 소계 · 블록 금액은 같음)
|
||||
|
||||
## 2 줄 읽기
|
||||
|
||||
| 줄 갈래 | EST | STC | 엑셀 |
|
||||
|---|---|---|---|
|
||||
| 제목 · 합계 | 19,107 | — | 5,846 |
|
||||
| 자원 | 7,609 | 1,918 | 3,485 |
|
||||
| 자원(형제 단가산출) | 356 | 9 | 37 |
|
||||
| 변수(값 · 공식) | 6,655 | 2,601 | 2,961 |
|
||||
| 율 | 1,125 | 114 | 511 |
|
||||
| 소계 | 1,795 | 1,072 | 1,296 |
|
||||
| 글 | 15,973 | 2,003 | 3,835 |
|
||||
| 표지 — 못 읽음 · 못 옮김 | 0 | 0 | 0 |
|
||||
| 표지 — 추정(품셈 조건 글) | 2,182 | 0 | 324 |
|
||||
| 표지 — 참고(조합 기계 · 당초 칸 · 후보 여럿 등) | 471 | 42 | 823 |
|
||||
|
||||
| 엑셀 ↔ 원파일 실제 짝 | 블록 | 줄 | 다름 |
|
||||
|---|---|---|---|
|
||||
| 거창 xlsx ↔ .estx | 61 | 1,467 | 0 |
|
||||
| STC 다섯(봉화 · 동부청영월 · 울진대흥 · 영덕계류 · 울진소광) | 203 | 9,277 | 0 |
|
||||
|
||||
- 울진대흥 엑셀 블록 33 = 원파일에 없는 블록(다른 판 파일) — 짝 대상 밖
|
||||
|
||||
| 기계 비목 세 줄 | EST | STC | 엑셀 |
|
||||
|---|---|---|---|
|
||||
| 블록 | 155 | 285 | 277 |
|
||||
| 줄 — 이름 = 「노 무 비 · 재 료 비 · 경 비」 | 0 | 1,391 | 1,109 |
|
||||
| 줄 — 「덤프트럭 노무비」 꼴 | 476 | 1 | 160 |
|
||||
|
||||
## 3 요소 이음(줄 수 · 이름 수)
|
||||
|
||||
| 갈래 | 쓰임 | 이어짐 | 별칭 | 조합 가름 | 찾음 · 시간당 이음 없음 | 후보 여럿 | 못 찾음(별칭 필요) | 우리 표에 없음 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 인력 | 5,089 | 5,089(47 이름) | — | — | — | 0 | 0 | 0 |
|
||||
| 기계 | 6,279 | 4,932(192) | 62(8) | 705(44) | 349(26 종) | 88 | 65(13) | 71(5) |
|
||||
| 재료 | 2,036 | 1,466(348) | — | — | — | 556(156) | 14(7) | — |
|
||||
| 유가 | 29 | 0 | — | — | — | 29(3) | — | — |
|
||||
| 환율 | 0 | — | — | — | — | — | — | — |
|
||||
| 형제 단가산출 | 402 | 402(97) | — | — | — | — | — | — |
|
||||
|
||||
- 기계 판정 없음 7 줄 · 고정 줄(갈래 밖) 59
|
||||
- 못 이은 것 많이 나오는 스무 이름 = 목록 「요소 이음」 시트(기계 = 대형브레이카 73 · 부착용집게 68 · 굴착기(타이어형) 56 · 진동파일해머 32 …)
|
||||
|
||||
## 4 [우리 단가로]
|
||||
|
||||
| 블록 판정 | 원파일 | 엑셀 |
|
||||
|---|---|---|
|
||||
| 모든 줄 우리 값 | 1,596 | 762 |
|
||||
| 이음 없는 줄 = 엑셀 단가 남음 | 388 | 212 |
|
||||
| 0 원 줄 있음 | 236 | 47 |
|
||||
| 셈 못 함 | 0 | 0 |
|
||||
|
||||
| 0 원 줄 까닭 | 줄 |
|
||||
|---|---|
|
||||
| 조합 다음 줄 · 시간당 경비 이음 없음(대형 브레이커 · 덤프트럭 자동덮개시설 등) | 252 |
|
||||
| 조합 다음 줄 · 기계 못 이음(부착용 집게 등) | 177 |
|
||||
| 재료 이음 · M01 값 0(공기주입기 · 시스템동바리 등) | 88 |
|
||||
| 기계 낱 줄 | 8 |
|
||||
|
||||
## 5 좌측 차림
|
||||
|
||||
| 항 | EST | STC | 엑셀 |
|
||||
|---|---|---|---|
|
||||
| 블록 | 1,849 | 371 | 1,021 |
|
||||
| 항목 · 제목 있는 항목 | 8,290 · 7,218 | 1,188 · 825 | 2,861 · 2,306 |
|
||||
| 가나다 소항목 | 1,725 | 28 | 298 |
|
||||
| 공식 묶음 · 글 줄 | 2,917 · 6,221 | 722 · 1,252 | 1,136 · 1,992 |
|
||||
| 조합 기계 · 그 항목 | 634 · 552 | 150 · 100 | 176 · 140 |
|
||||
| 제목 없는 블록 | 0 | 86 | 69 |
|
||||
| 왕복 같음 · TS = 파이썬 거울 | 1,849 · 1,849 | 371 · 371 | 1,021 · 1,021 |
|
||||
|
||||
- 조합 기계 수 > 항목 수 = 비목 세 줄 기계가 조합일 때 같은 제목 항목이 겹치고 줄이 엇갈림(83 블록 · 흠 2)
|
||||
|
||||
## 6 연결 찾기
|
||||
|
||||
| 원천 | 확정 | 근접 | 없음 | 기계 작업 | 합 |
|
||||
|---|---|---|---|---|---|
|
||||
| EST 원파일 | 316 | 771 | 636 | 126 | 1,849 |
|
||||
| STC 원파일 | 23 | 94 | 182 | 72 | 371 |
|
||||
| 실무 엑셀 | 205 | 260 | 472 | 84 | 1,021 |
|
||||
|
||||
- 파일별 수 = 목록 「연결 판정」 시트 · 원파일 「못 셈」 134 = 전수 도우미 흠 → 고친 사본으로 다시 잼(확정 2 · 근접 17 · 없음 115) · 엑셀 「못 셈」 17 = 같은 흠(다시 안 잼)
|
||||
- 확정 참고(블록) — 수량 114 · 단가 105 · 율 값 41 · 수량 끝자리 39 · 규격 37 · 옵션 확인 34 · 품 숫자 18 · 끝수 1 · 확정 다시 셈 다름 0
|
||||
- 근접 까닭(블록) — 줄 없음 536 · 기계 규격 296 · 율 줄 239 · 비목 223 · 이름 다름 186 · 율 대상 77 · 공구손료 유무 46 · 자원 25 · 단위 환산 4
|
||||
- 엑셀 ↔ 원파일 같은 블록 판정 — 264 중 246 같음(93.2 %)
|
||||
- 원파일 「없음」 818 구조 — 후보 0 = 294 · 가까운 후보 있으나 줄 짝 반 못 넘음 = 524
|
||||
|
||||
| 「없음」 무작위 쉰(어림) | 수 | 비율 |
|
||||
|---|---|---|
|
||||
| 우리 기초에 그 공종 없음(구역화물 운임 · 하차 요금 · 지자체 기준 · 자체 품 · 구조물 묶음 · 부품만) | 19 | 38 % |
|
||||
| 우리 기초 있는데 못 찾음 | 31 | 62 % |
|
||||
| └ 기계 비목 세 줄 탓 | 12 | 24 % |
|
||||
| └ 줄 구성 · 율 · 이름 다름(손율 재료 · 유리알 더함 · 이름 덧말) | 15 | 30 % |
|
||||
| └ 형제 묶음 · 못 셈 | 4 | 8 % |
|
||||
|
||||
| 「근접」 무작위 서른(어림) | 수 | 비율 |
|
||||
|---|---|---|
|
||||
| 같은 공종 · 차이는 표현(율 비목 · 비목 세 줄 · 조합 한 줄) — 더 붙일 수 있음 | 7 | 23 % |
|
||||
| 맞는 우리 기초가 따로 있음 — 후보 고르기 틀림 | 8 | 27 % |
|
||||
| 같은 공종 · 로직 실제로 다름 — 근접 맞음 | 4 | 13 % |
|
||||
| 우리 기초에 공종 없음 — 엉뚱한 근접 | 11 | 37 % |
|
||||
|
||||
## 7 화면(8005 · 5178 · 일반 계정 개인 층)
|
||||
|
||||
| 파일 | 블록 | 금액 = 원본 | 확인 |
|
||||
|---|---|---|---|
|
||||
| 거창 .xlsx | 1 측구터파기 | 5,323 | [연결 찾기] 확정 GF000099 → [바꾸기] 5,286 → [되돌리기] 5,323 |
|
||||
| 거창 .xlsx | 2 파쇄암 | 30,381 | 조합 가 · 나 · 다 항목 · 근접 GF000268 = 전수 · [우리 단가로] 23,747 = 전수(브레이커 0 원) |
|
||||
| 거창 .xlsx | 15 · 41 · 60 | 94,215 · 52,796 · 1,675 | 15 = 없음(후보 14 · 「못 셈」 아님) · 60 = 「기계 한 줄 단가산출」 띠 |
|
||||
| 거창 .estx | 25 장비운반 | 271,869 | 엑셀 HP 에 붙음 |
|
||||
| 봉화 .STC | 14 · 55 · 93 | 26,144 · 16,629 · 36,282 | 55 기계 = 「재 료 비 · 노 무 비 · 경 비」 세 줄 · 93 = 없음(후보 17) |
|
||||
| 북부청임도 .xls | 16 · 31 | 2,264 · 134,304 | 31 조합 나 · 다 항목(어두움) |
|
||||
|
||||
- 콘솔 오류 0(ResizeObserver 알림만) · 밝음 · 어두움 · 가로 넘침 0(1274 폭)
|
||||
|
||||
## 흠
|
||||
|
||||
| # | 심각 | 흠 | 예 | 고칠 길 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 심각 | STmate 기계 = 비목 세 줄(이름 「노 무 비」 · 단위 X) | 봉화 55 크로울러드릴 세 줄 · STC 285 블록 1,391 줄 · 엑셀 277 블록 | X 코드 L · M · S 세 줄 → 기계 한 묶음 줄 |
|
||||
| 2 | 심각 | EST 「덤프트럭 노무비 · 재료비 · 경비」 세 줄 · 조합이면 항목 엇갈림 | main 360 같은 제목 항목 셋 · 155 블록 · 엇갈림 83 블록 | 세 줄 묶은 뒤 조합 가름 |
|
||||
| 3 | 심각 | 단가산출 목록 열기 65~75 초 | SK 16 개 층 = 파일 읽기 4,137 번(`M02_Labor_Ref.users`) | 쓰는 곳 색인 한 번 |
|
||||
| 4 | 심각 | 찾은 기계 26 종 시간당 경비 이음 없음 → 우리 단가 0 원 · 엑셀 값 남음 | 거창 2 브레이커 0 원 · 349 줄 · 0 원 블록 283 | M01 시간당 짝 채움 · 못 이으면 엑셀 값 + 표지 |
|
||||
| 5 | 보통 | 원파일만 올리면 블록 목록 판 없음(STC 호표 0) | sub3임시_봉화.STC | 격자 없어도 목록 판 |
|
||||
| 6 | 보통 | 맞는 우리 기초 두고 다른 후보 근접 | 플륨관 설치 → 강관압입추진 · 서른 중 8 | 이름 · 절 근거 후보 먼저 |
|
||||
| 7 | 보통 | 엉뚱한 근접(인부 한 줄 짝) | 떼 하차비 → 텍코팅 · 서른 중 11 | 근접에 이름 근거 또는 핵심 줄 둘 |
|
||||
| 8 | 보통 | 이름 거의 같은 기초 두고 후보 0 | 비탈규준틀 ↔ GF000151 · 후보 0 = 294 | 덧말 뺀 이름 · 절 번호로 모음 |
|
||||
| 9 | 보통 | 엑셀 ↔ 원파일 같은 블록 판정 다름 | 동부청영월 2 · 264 중 18 | 이름 맞대기 전 규격 · 단위 꼬리 뗌 |
|
||||
| 10 | 보통 | 재료 · 유가 후보 여럿 → 이음 없음 | 재료 556 줄 · 유가 29 줄 | 규격 · 단위로 좁힘 · 유가 전국 기본 |
|
||||
| 11 | 보통 | 전수 도우미 형제 값 글자 → 「못 셈」(시험 도구만 · 화면 정상) | 원파일 134 · 엑셀 17 블록 | `helper_810_4_sweep` str() 뗌 |
|
||||
| 12 | 가벼움 | 조합 첫 줄 기계 칩 = 원래 조합 글 | 「굴삭기(무한궤도) 0.7+브레이카」 | 칩도 그 기계 이름 |
|
||||
| 13 | 가벼움 | 소계 줄 검산 틀림 · 표지 없음 | 북부청임도 33 · 478.8 ↔ 3,897.1 | 얼린 줄도 소계 식에 |
|
||||
| 14 | 가벼움 | 기계 줄 찾기 판정 없음 | 진동 로울러(핸드가이드식) · 7 줄 | 찾기 붙임 |
|
||||
| 15 | 가벼움 | 올린 직후 몇 초 [내 단가산출로] 409 | 거창 xlsx 블록 넷 | 올림 답 전 보관 끝냄 |
|
||||
| 16 | 가벼움 | 엑셀 블록에도 「원파일 확정」 글 | 거창 xlsx 2 | 갈래 맞는 글 |
|
||||
|
||||
## 사용자가 정할 것
|
||||
|
||||
| # | 정할 것 | 갈래 |
|
||||
|---|---|---|
|
||||
| 1 | 공구손료 율을 실무는 재료비 · 우리는 경비 — 이 비목 차이를 로직(근접)으로 볼지 · 값(참고 · 확정)으로 볼지 | 근접 서른 중 4 블록이 이것 하나로 근접(1 블록은 자재 이름도) |
|
||||
| 2 | 원파일(.estx · .STC)만 올린 경우에도 단가산출 블록 목록을 열지 | 지금 STC 단독은 화면 길 없음 |
|
||||
| 3 | 시간당 경비 자료 없는 기계 26 종 — [우리 단가로] 때 엑셀 값 그대로 · 0 원 + 표지 · M01 자료 채움 중 | 대형 브레이커 · 부착용 집게 · 굴착기(타이어형) 등 |
|
||||
| 4 | 구역화물 운임 · 하차 요금 · 지자체 기준 · 자체 품 블록(없음 쉰 중 38 % 어림)을 「품셈 밖」 표지로 연결 대상에서 뺄지 | 연결 「없음」 해석 |
|
||||
@@ -0,0 +1,79 @@
|
||||
# 814-1 불러오기 결정 반영 — 전후 전수 보고(요구 742 ~ 750 · 방향 장부 E 표 1 ~ 10)
|
||||
|
||||
- 대상 = 812-12 와 같은 자 — 원파일 EST 6 · STC 7(산근 2,220 블록) + 실무 엑셀 11(1,021 블록)
|
||||
- 전 = 812-12 보고 수 + 같은 자로 다시 잰 수(코드 바꾸기 전) · 후 = 814-1 반영 판(커밋 56a3e05 · 8914b21 · bed35e3)
|
||||
- 자세한 표 = `_목록_814-1_정합성.xlsx`(요약 전 · 후 · 연결 · 못 이은 줄 · 0 원 줄)
|
||||
- 화면에서 만든 SK 둘 · 올린 파일 셋 모두 지움 · 마스터 별칭 · 별칭 장부 · storage 층 사본 안 건드림
|
||||
|
||||
## 요약
|
||||
|
||||
| 잣대 | 전 | 후 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 금액(엑셀 단가 그대로 셈 = 원본) | 원파일 2,220 · 엑셀 1,021 = 100 % | 원파일 2,220 · 엑셀 1,021 = 100 % | 그대로 |
|
||||
| 못 읽음 표지 | 0 | 0 | 그대로 |
|
||||
| 인력 · 형제 단가산출 이음 | 100 % · 100 % | 100 % · 100 % | 그대로 |
|
||||
| 기계 이음 | 90.8 %(5,699 / 6,279 줄) | **95.7 %**(4,601 / 4,807 줄 — 세 줄 묶은 뒤 줄 수) | 좋아짐 |
|
||||
| 기계 — 찾았는데 시간당 경비 없음 | 349 줄(26 종) | **0** | 풀림 |
|
||||
| 재료 이음 | 72.0 %(1,466 / 2,036) | **87.6 %**(1,784 / 2,036) | 좋아짐 |
|
||||
| 유가 · 전력 이음 | 0 %(0 / 29) | **100 %**(29 / 29) | 풀림 |
|
||||
| [우리 단가로] 블록 전부 우리 값(= 우리 정보로 만들어짐) | 원파일 71.9 %(1,596) · 엑셀 74.6 %(762) | 원파일 **85.5 %**(1,899) · 엑셀 **79.7 %**(814) | 좋아짐 |
|
||||
| [우리 단가로] 0 원 줄 있는 블록 | 원파일 236 · 엑셀 47 | 원파일 115 · 엑셀 4 | 줄어듦 |
|
||||
| STmate 기계 비목 세 줄(문서 줄 쌓기) | STC 285 블록 · 1,391 줄 | 남음 STC 2 블록 · 엑셀 6 블록(번지가 셋으로 갈린 꼴) | 풀림 |
|
||||
| 조합 기계 항목 겹침(같은 기계 항목이 여럿) | 83 블록 | **0** | 풀림 |
|
||||
| 차림 왕복 · TS 거울 | 100 % | 100 %(812-3 시험 · 7 통과) | 그대로 |
|
||||
| 1,513 M01 문서 금액 | — | 기본 1,513 같음 · 옵션 표본 18,797 중 다름 1(채택된 차이 그대로) | 그대로 |
|
||||
| 연결 판정(보조) 원파일 확정 · 근접 · 없음 · 기계 작업 | 339 · 865 · 818 · 198(15.3 %) | **463** · 784 · 761 · 212(20.9 %) · 못 셈 0 · 확정 다시 셈 다름 0 | 좋아짐 |
|
||||
| 연결 판정(보조) 엑셀 확정 · 근접 · 없음 · 기계 작업 | 205 · 260 · 472 · 84(20.1 %) | 211 · 271 · 447 · 92(20.7 %) · 못 셈 0 | 조금 |
|
||||
|
||||
## 항목별(E 표)
|
||||
|
||||
| # | 고친 것 | 화면 · 수 |
|
||||
|---|---|---|
|
||||
| 1 | 올리기 = 파일 하나 · 같은 공사 붙이기 걷음 | 엑셀이든 .estx · .STC 든 따로 새 작업 · 「같은 공사 엑셀에 붙음」 글 없음 |
|
||||
| 2 | 확장자 파일 = 단가산출표 판(코드 · 이름 · 규격 · 단위 · 재료비 · 노무비 · 경비 · 합계) + 산출근거 | 봉화 .STC 하나 → 65 블록 목록 · [내 단가산출로] = 산출근거 · 일위대가 안 만듦(EST 도 HeadMainDat 안 씀) |
|
||||
| 3 | 연결 찾기 = 엑셀 이름 규격 · 단위 꼬리 뗀 이름(「구조물터파기(인력) 경질토사 / M3」 → 「구조물터파기(인력)」) | 보이는 이름 그대로 · STmate 블록 규격도 근거 칸에 |
|
||||
| 4 | 기계 세 줄 = 기계 한 줄 · 조합 다음 줄 기계마다 한 항목 | 봉화 55 크롤러드릴 「노 무 비 · 재 료 비 · 경 비」 → 「크롤러드릴(탑승유압식) 110㎾」 한 줄 · main 360 덤프 자동덮개 시설 항목 셋 → 하나 · 금액 그대로 |
|
||||
| 5 | 시간당 경비 계산식 기계 목록에 빠진 기계 더함 | 384 대 가운데 351 대 더함(기계경비 92 · 조종원 직종 입력 18 · 조종원 없음 43 · 연료 없음 7 · 손료만 198) · 못 넣음 33 대(기계표 가격 · 계수 · 연료 종류 빔 · 잡재료율 「13∼18」 범위) · 조합(굴착기 + 대형 브레이커 · 부착용 집게) = 우리 조합 식 GC000271 · 270 한 줄 — 거창 2 [우리 단가로] 23,747 → 25,772(브레이커 0 원 풀림) |
|
||||
| 6 | 공구손료 율 비목 차이(재료비 ↔ 경비) = 참고 | 거창 41 합판거푸집 = 근접 → 확정(참고 「공구손료 비목」) |
|
||||
| 7 | 못 이은 줄 까닭 · 「없음」 띠 | 이음 없는 자원 띠 = 「대형 브레이커 (조합 — 우리 조합 식 GC000271 에 듦)」 꼴 · 문서 줄 설명 = 「이음 없음 — 자재 없음 · 엑셀 단가 그대로」 꼴 · 연결 「없음」 = 「우리 기초 없음 — 우리 정보로 셈 n/m 줄」 · 형제만 = 「조합」 |
|
||||
| 8 | 유가 = 전국평균 + 지역 옵션 · 전력 = 일반용(갑)Ⅰ 저압 + 계약종별 옵션 | 경유 · 등유 → `MO:경유:{S_지역}`(기본 전국평균) · 전력 → 114.4 원(엑셀 114.8 = 검산 칸) |
|
||||
| 9 | 재료 = 지역 꼬리 뗀 이름 · 규격 정확 일치 | 유리알 1호 고휘도 · 합판(내수) 12t(서울) · 외송각재 · 철못 N 25 · 구역화물 8.0톤 30km 이음 |
|
||||
| 10 | 가벼움 여섯 | 조합 첫 줄 칩 = 「굴삭기(무한궤도) 0.7」 · 북부청임도 33 소계 검산 맞음 · 기계 찾기 판정 없는 줄 7 → 0 · 올린 직후 409 = HP 보이기 전 산근 보관 · 엑셀 블록 「엑셀 읽기 확정」 · 전수 도우미 str() 뗌 |
|
||||
|
||||
## 남은 못 이음 — 까닭별(후)
|
||||
|
||||
| 갈래 | 까닭 | 줄 |
|
||||
|---|---|---|
|
||||
| 기계 | 후보 여럿(사람 고름 · C3 협의 중) | 88 |
|
||||
| 기계 | 못 찾음 — 별칭 대기 | 65 |
|
||||
| 기계 | 우리 기계표에 없음 | 53 |
|
||||
| 재료 | 후보 여럿(정확 일치 없음 — 접착제 · 유로폼 패널 · 각재 외송 …) | 238 |
|
||||
| 재료 | 우리 자재표에 없음 | 14 |
|
||||
| 갈래 밖 | 견적 · 운임 · 수수료(구역화물자동차 · 도선료 · 치즐 견적 …) | 59 |
|
||||
|
||||
| [우리 단가로] 0 원 줄(후) | 줄 | 뜻 |
|
||||
|---|---|---|
|
||||
| 조합 다음 줄 이음 · 덤프트럭 자동덮개시설(노무비 · 재료비 칸) | 82 | 부착 장비 = 손료(경비)만 — 노무 · 재료 칸 0 이 맞음 |
|
||||
| 조합 다음 줄 못 이음(굴착기 후보 여럿 · 별칭) | 46 | 사람 고름 대기 |
|
||||
| 재료 이음 · 우리 값 0(급수호스 · 시험 수수료 · 공기주입기 · 시스템동바리 …) | 102 | 우리 자재표 값 없음 |
|
||||
| 기계 낱 줄(발전기 노무비 칸 등) | 11 | 그 기계에 그 비목 없음 |
|
||||
|
||||
- 연결 「확정」 참고(원파일) — 수량 269 · 공구손료 비목 154(6 번) · 단가 차이 97 · 옵션 확인 94 · 수량 끝자리 72 · 품 숫자 65
|
||||
- 「없음」 = 흠 아님(요구 745) — 띠 글 「우리 기초 없음 — 우리 정보로 셈 n/m 줄」 · 근접 까닭 1위 = 줄 없음 1,361 · 기계 규격 475(후보 고르기 C3 협의 몫)
|
||||
|
||||
## 남은 흠
|
||||
|
||||
| # | 흠 | 예 | 갈래 |
|
||||
|---|---|---|---|
|
||||
| 1 | EST 「〇〇 노무비 · 재료비 · 경비」 가운데 수량 식이 다른 세 줄은 못 묶음 | main 360 덤프 재료비 × To | 원파일 로직 그대로(값 묶음으로 못 접음) |
|
||||
| 2 | STmate 세 줄이 번지 셋(A01 · A02 · A03)으로 갈린 꼴은 안 묶음 | 울진대흥 6 크로울러드릴 | 묶으면 번지 율 식이 깨짐 · 8 블록 |
|
||||
| 3 | 재료 지역 옵션 화면 칸 없음 — 서울 줄 기본으로 이음 | 합판(내수) 서울 | 자재표 = 키마다 지역(우리 기초도 서울 고정 검색어) |
|
||||
| 4 | 원파일 단가산출 목록 줄 아래 글 「일위대가 밖」 | 봉화 .STC 목록 | 일위대가를 안 만드니 늘 그 글 |
|
||||
| 5 | 연결 판정 띠(「우리 기초 없음 — 우리 정보로 셈」)는 [연결 찾기] 다시 누른 SK 부터 | 옛 SK | 기록된 판정에 칸 없음 |
|
||||
|
||||
## 화면(8005 · 5178 · 일반 계정 개인 층 · 「sub3임시」)
|
||||
|
||||
- 봉화 .STC 하나만 올림 → 단가산출표 판 65 블록 · 55 암발파 = 크롤러드릴 한 줄 · 검산 같음 · [우리 단가로] 16,629 → 16,020
|
||||
- 거창 .xlsx 하나만 올림 → 2 측구터파기(연암) = 가 · 굴착기 / 나 · 대형 브레이커 / 다 · 치즐 항목 · 브레이커 띠 「조합 — 우리 조합 식 GC000271 에 듦」 · [우리 단가로] 30,381 → 25,772 · 「같은 공사」 글 없음
|
||||
- 밝음 · 어두움 · 1366×768 가로 넘침 0 · 콘솔 오류 0(로그인 전 세션 401 하나 뺌) · 사진 = tmp(셋)
|
||||
- 만든 SK 둘 · 올린 HP 셋 지움
|
||||
@@ -0,0 +1,85 @@
|
||||
# 815-1 불러오기 이음 — 전후 전수 보고(요구 753 ~ 765 · 방향 장부 K 장 K1 · K2 · K3 · K5)
|
||||
|
||||
- 대상 = 814-1 과 같은 자 — 원파일 EST 6 · STC 7(산근 2,220 블록) + 실무 엑셀 11(1,021 블록)
|
||||
- 전 = 814-1 뒤 판(지금 코드로 다시 잼 · 814-1 보고 「후」와 수 같음) · 후 = 815-1 판(커밋 55ad1bf · 030a843 · 8edd8ad)
|
||||
- 자세한 표 = `_목록_815-1_정합성.xlsx`(요약 전후 · 못 이은 줄 = 별칭 넣을 후보 · 견적 줄 · 0 원 줄)
|
||||
- 마스터 자료 · 별칭 값 · storage 층 사본 안 건드림 · 별칭 이음은 시험 안 임시 별칭으로만 확인
|
||||
|
||||
## 요약
|
||||
|
||||
| 잣대 | 전(814-1 뒤) | 후(815-1) | 합친 뒤(815-1 + 815-2 + 815-3) | 판정 |
|
||||
|---|---|---|---|---|
|
||||
| 금액(엑셀 단가 그대로 셈 = 원본) | 원파일 2,220 · 엑셀 1,021 = 100 % | 같음 100 % | 같음 100 % | 그대로 |
|
||||
| 못 읽음 표지 | 0 | 0 | 0 | 그대로 |
|
||||
| 인력 · 형제 · 유가 이음 | 100 % · 100 % · 100 % | 같음 | 같음 | 그대로 |
|
||||
| 기계 이음 | 95.7 %(4,601 / 4,807) | 94.9 %(4,564) | **95.7 %(4,601)** — 다섯 칸 옮김으로 37 줄 돌아옴 | 옛 마스터 별칭 다섯이 아직 기계 줄 「별칭」 칸에 안 옮겨짐(815-2 몫) — 옮기면 37 줄 돌아와 95.7 % |
|
||||
| 재료 이음 | 87.6 %(1,784 / 2,036) | **88.0 %**(1,792) | 88.0 %(1,792) | 표기 맞추기로 8 줄 더 |
|
||||
| 못 이은 재료 | 252 줄 · 105 종 | **244 줄 · 100 종** | 244 줄 · 100 종 | 다섯 종 풀림 |
|
||||
| 견적 · 운임 · 수수료 줄 | 59 줄 = 「갈래 밖」 못 이음 | **59 줄 = 「견적」 그대로**(못 이음에서 뺌) | 59 줄 = 「견적」 그대로 | 우리 값 있는 자재로 이어진 것 0 |
|
||||
| [우리 단가로] 모두 우리 값 | 원파일 1,899(85.5 %) · 엑셀 814(79.7 %) | 원파일 1,853(83.5 %) + 견적만 남음 19 · 엑셀 815(79.8 %) + 12 | 원파일 **1,891(85.2 %)** + 19 · 엑셀 815(79.8 %) + 12 | 아래 「줄어든 까닭」 |
|
||||
| [우리 단가로] 0 원 줄 있는 블록 | 원파일 115 · 엑셀 4 | 원파일 155 · 엑셀 9 | 원파일 **147** · 엑셀 9 | 아래 「줄어든 까닭」 |
|
||||
| 차림 왕복 · 시험 | 100 % | 100 %(812-3 · 810-2 · 810-3 통과) | 100 % | 그대로 |
|
||||
|
||||
### 합친 뒤 달라진 수(815-3 · 같은 자로 다시 잼)
|
||||
|
||||
- 기계 이음 94.9 → **95.7 %** — 815-2 가 옛 마스터 별칭 다섯을 기계 줄 「별칭」 칸으로 옮김 · 못 찾음(별칭 필요) 102 → 65 줄 · 풀린 기계 = 아스팔트페이버(피니셔) 1.7m · 3.0m · 버킷준설기 7.46kW · 바이브레이트 봉상후렉시블
|
||||
- [우리 단가로] 모두 우리 값(원파일) 1,853 → **1,891** — 엑셀 단가 남음 → 우리 값 30 블록(위 다섯 별칭) · 0 원 줄 → 우리 값 8 블록(H-PILE · POST-PILE — 발전기 조종원 노무비 참)
|
||||
- 0 원 줄 블록(원파일) 155 → **147** — 위 8 블록
|
||||
- 우리 계가 바뀐 블록 128(올라감 98 · 내려감 30) — 올라감 = 815-3 시간당 경비 식 고름(기계표 조종원 > 0 = 조종원 든 식 먼저 · 래머 80kg · 플레이트 콤팩터 1.5ton · 진동롤러(핸드가이드식) 0.7ton · 발전기 100kW · 예 EST 되메우기및다짐(램머) 1,569 → 6,535 · 엑셀 원본 10,141 쪽으로) · 내려감 = 별칭 이음 블록
|
||||
- 금액(엑셀 단가 그대로) · 재료 · 견적 · 차림 = 그대로
|
||||
|
||||
### 「모두 우리 값」이 줄어든 까닭(블록 옮김 전수)
|
||||
|
||||
| 옮김 | 블록 | 까닭 |
|
||||
|---|---|---|
|
||||
| 우리 값 → 엑셀 단가 남음 | 30 | 아스팔트페이버(피니셔) 29 · 버킷준설기 1 — 옛 마스터 별칭 장부를 안 읽게 됨 · 815-2 가 다섯을 기계 줄 칸으로 옮기면 그대로 돌아옴 |
|
||||
| → 0 원 줄 | 45 | 치즐(0.7㎥) 39 · 치즐(0.4㎥) 6 — 전에는 「바탕 이름」 길이 **엉뚱한 치즐 0.2㎥ 줄**로 이어 값이 있어 보였음(지어 맞추기) · 이제 정확히 같은 치즐 0.7 · 0.4 줄로 잇는데 그 줄 값 칸 다섯이 빔(EST DB 자체에 값 없음 · 비고에만 「가격정보 223,000」) = 자재표 보강(J 장) 대상 |
|
||||
| 엑셀 단가 남음 → 우리 값 | 4 | 표기 맞추기 |
|
||||
|
||||
## K1 EST 원파일 자재 = 참조 코드
|
||||
|
||||
| 원파일 | 자재 자원 | 공사 직접 번호(M0000000…) | 우리 표 코드와 같음 → 그 줄로 이음 |
|
||||
|---|---|---|---|
|
||||
| main.estx(EST DB 판 그 자체) | 23,980 | 8 | **23,965 / 23,965**(다른 줄 0) |
|
||||
| 거창 · 장수 · 진안 · 임도용역 · 통영 | 239 | **235** | 0 — 나머지 4 도 우리 표에 없는 코드(아연도철선 M0004113991 …) |
|
||||
|
||||
- 찾은 것: 방향 장부 G4 의 「24,219 중 23,981(99 %)」은 거의 다 main.estx(우리 자재표를 만든 바로 그 EST DB 파일) 몫 · **실제 공사 파일 다섯의 자재는 99 % 가 공사가 매긴 직접 번호** → 코드로 잇는 몫 0 · 이름 · 별칭 길로 이음
|
||||
- 공사 직접 번호는 파일마다 다른 품목(거창 「치즐(0.7㎥)」 = M0000000001 ↔ 우리 표 M0000000001 = 「Air Hammer」) → 코드 잇기에서 뺌(시험에 굳힘)
|
||||
- 화면: 거창 .estx 단산10 줄떼심기 = 천연잔디 이름 길로 이음 · 이음 없는 자원 0
|
||||
|
||||
## K2 「별칭」 칸 읽기(풀이 정본 `master_alias.py` · 잇기 `M02_Elem_Alias.py`)
|
||||
|
||||
- 풀이 = 콤마 · 전각 콤마 「,」 · 큰따옴표 조각 통째(속 콤마 보존) · 앞뒤 빈칸 뗌 · 같은 것 한 번
|
||||
- 뜻 = 이름만 적은 별칭 → 가져온 규격이 그 줄 규격과 정규화해 같을 때 · 「이름 규격」을 적은 별칭 → 통째 같을 때 · 같은 별칭이 여러 줄 = 같은 품목 지역 줄이면 줄 지역 › 서울 · 아니면 「후보 여럿」
|
||||
- 읽는 곳(모두 같은 길) = 단가산출 불러오기 재료 · 인력 · 유가 · 기계 찾기 · 일위대가 호표 줄(자재 · 노임 · 기계)
|
||||
- 옛 층 별칭 장부를 요소 잇기에서 읽는 곳 걷음 — 기계 찾기 · 호표 자재 짝 · 단가산출 호표(장부 import 뗌)
|
||||
- 남긴 곳(815-2 몫) — 기계 [저장] 때 고른 짝을 옛 장부에 쓰는 `M02_Machine_Find.learn` · 일위대가 [확정]의 장부 쓰기(`M02_Hopyo_Build`) · 장부 화면 · 길
|
||||
- 시험(임시 별칭): 「"크레인(트럭) 15Ton"」 → 그 크레인 · 「아스팔트페이버(피니셔)」 → 규격으로 피니셔 3m · 「결속선(철선)」(서울 · 부산 줄) + #20, 0.9M/M → 서울 줄 · 규격 #8 = 안 이음 · 「특인」 → 특별인부 · 「디젤유」 → 경유 전국평균 · 같은 별칭 두 기계 = 후보 여럿
|
||||
|
||||
## K3 재료 표기 맞추기
|
||||
|
||||
- 정규화 = M/M · m/m · ㎜ = mm · 곱셈표 × · x · X · * · 지름 Φ · φ · ø · D= · 괄호 · 빈칸 · 전각 · # · 지역 꼬리(「, 서울」) — 정확히 같을 때만(가까운 규격 대신 잇지 않음) · 낱말 순서 뒤집힘(결속선(철선) ↔ 보통철선(결속선))은 별칭 몫
|
||||
- 정확 일치를 옛 「바탕 이름」 길보다 먼저 봄(엉뚱한 치즐 0.2 잇기 바로잡음)
|
||||
- 풀린 다섯 종 = PVC파이프(일반관),서울 D=100mm(VG1) · 강관동바리 Φ48.6x2.4,L=2.6~4.0 · 보통철선(결속선) #20 , 0.9M/M · 산림종자(비수리) · 산림종자(안고초)
|
||||
- 남은 못 이은 재료 244 줄 · 100 종 = 후보 여럿 230 · 자재표에 없음 14 → 목록 「못 이은 줄」 시트(가져온 이름 · 규격 · 쓰임 수 · 가까운 우리 줄 셋 · 넣을 별칭 예)
|
||||
|
||||
## K5 견적 줄
|
||||
|
||||
- 대상 = 원파일 EST 견적(국가지점번호판 · 측량 수수료 · 도선료) · STmate · 엑셀 고정 코드 줄(구역화물 · 하차비 · 덤프자동덮개 · 치즐 견적) — 59 줄
|
||||
- 우리 자재에 이어지고(코드 · 별칭 · 정확 일치) **우리 값이 있으면** 재료 줄(우리 값 · 엑셀 값은 검산) · 아니면 그대로(이름 · 값) + 표지 「견적 — 그대로(이름 · 값) · [단가 갱신] 대상 아님」 · 문서 줄 설명 같은 글 · 화면 「견적 n」 배지(「이음 없는 자원」 수에서 뺌)
|
||||
- 지금 59 줄 모두 그대로 — 치즐(0.7m3) 견적 5 줄은 우리 치즐 0.7 줄이 있으나 값 칸이 비어 견적 값 유지(0 원 막음)
|
||||
|
||||
## 화면(8005 · 5178 · 일반 계정 개인 층 · 「sub3임시_거창.estx」)
|
||||
|
||||
- 단산22 국가지점번호판 → 띠 「견적 2」(파랑) · 「국가지점번호판 (견적 — 그대로(이름 · 값) · [단가 갱신] 대상 아님) · 이음 고르기」 · 검산 같음 · 이음 없는 자원 배지 없음
|
||||
- 밝음 · 어두움 · 1366×768 가로 넘침 0 · 콘솔 오류 0(로그인 전 세션 401 하나 뺌) · ORCA 자기 탭 확인 · 사진 = tmp(셋)
|
||||
- 만든 SK 셋 · 올린 HP 둘 지움(같은 계정 다른 창이 만든 SK-000004 ~ 7 은 그대로)
|
||||
|
||||
## 남은 흠 · 알림
|
||||
|
||||
| # | 내용 | 갈래 |
|
||||
|---|---|---|
|
||||
| 1 | 옛 마스터 기계 별칭 다섯이 칸으로 옮겨지기 전까지 기계 37 줄 · 30 블록 못 이음 | **풀림**(합친 뒤 95.7 %) |
|
||||
| 2 | 치즐 0.7 · 0.4 등 우리 자재 줄 값 칸 빔 — 정확히 이으니 0 원 줄 102 → 153 | 자재표 보강(J 장 · 값 출처 미정) |
|
||||
| 3 | 견적 값묶음 줄 공식 칸에 「[object Object]」 글(814-1 전부터 · 값묶음 줄 꼴) | 화면 흠 · 이번 고침 밖 |
|
||||
| 4 | test_778_5 일위대가 기계 줄 표(이어짐 162 · 고를 것 3 · 못 이음 25)가 굳힌 수(180 · 10 · 0)와 다름 — 고치기 전 판도 같은 수(812-9 뒤부터) | **풀림(815-3)** — 진짜 흠: 호표 기계 글 「운 반 : 덤프트럭(4.5Ton)」 · 「…[hr] : 90%」 머리 · 꼬리를 812-9 찾기가 안 뗌 → 뗌 · 이어짐 187 · 고를 것 1 · 못 이음 2(조합 한 줄 · 괄호 깨짐) |
|
||||
@@ -565,3 +565,304 @@ M01은 지금 상당히 잘 개발이 되었어. 일위대가 불러오기의
|
||||
## 요구 713 (11-09 · 사용자 · 제안대로 진행(엑셀 읽기기 새로 + 연결 기준 = 로직) · 가져온 단가산출이 좌측에서 읽히게(나열만 X))
|
||||
|
||||
다음으로 중요한 점은 경쟁사의 단가산출을 가져왔다 가정하고 현재의 경쟁사 단가산출은 좌측영역에서 제대로 읽기가 힘든 그냥 노무비, 경비, 재료 1,2, 3 형태로 죽 나열만 되어있음. 분석도 중요하지만 보여지는것도 중요함. 너의 제안대로 잘 끝까지 진행해줘
|
||||
|
||||
## 요구 714 (11-09 · 사용자 · 판단 셋 답 — 1 보류 · 2-1 율 값 = 값(로직 아님) · 2-2 기계 이름 = 별칭 조사 · 3 1 원은 로직만 맞으면 됨)
|
||||
|
||||
1번은 생각할 시간이 필요.
|
||||
2-1번 율값 관련 내가 로직과 값을 구분하라고 했잖아. 로직은 어떤 계산을 할때 뭐를 곱하는지 뭐를 더할지 같은 것들이고 여기에 사용된 값은 결국 언제든 업데이트 될수 있다고 불러오기 기능들에서에서 초기에 검산을 위해서는 겂을 사용하겠지만 결국은 우리의 m01의 값을 사용할 수 있어야해. 그게 현재 m02의 단가산출에 있는 값 업데이트 기능이고 이해해?
|
||||
2-2번 기계 이름이 다를경우 별칭을 사용하는게 어떨까해. 우리는 품셈원문의 이름을 사용하고 있기도 하고 m01 단가산출에도 별칭으로 경쟁사와 정보가 다를경우 승인하고 별칭으로 등록하는게 있잖아. 조사해봐
|
||||
3. 1원 차이는 로직만 맞다면 문제 없음.
|
||||
|
||||
## 요구 715 (11-09 · 사용자 · 수량 추가 설명 요청 · 1번 계속 대기 · 기계 230 블록 뜻 물음)
|
||||
|
||||
수량 관련은 추가 설명 요청
|
||||
|
||||
1번 계속 대기
|
||||
2번 기계 230 블럭은 경쟁사 프로그램에서 만들어둔 기계의리스트라는 말임?
|
||||
|
||||
## 요구 716 (11-09 · 사용자 · 하나씩 — 기계 230 블록: 굴삭기 0.7 = 바스켓 크기 · 기계 옵션값 변경 또는 미리 만들어 두기 · 확인부터)
|
||||
|
||||
일단 하나씩 하자. 기계230블럭에서 굴삭기 0.7에서 0.7은 바스켓의 사이즈로 보이는데 기계의 옵션값 변경을 하면 되는거 아닌가? 아니면 미리 만들어 두던가 확인부터 해봐.
|
||||
|
||||
## 요구 717 (11-09 · 사용자 · 우리 단가산출에서 기계 · 옵션 변경이 안 되는 게 문제 — 기계가 들어오면 상단 옵션에 기계 선택 · 하위 옵션 · 그 뒤 연결 찾기 방향 · 확인부터)
|
||||
|
||||
1. 그것보다 중요한 건 우리 단가산출에서 기계를 변경하거나 옵션을 변경하는게 안되는게 문제아니야? 그렇다면 기계가 단가산출에 들어오면 상단 옵션에 기계선택이나 하위 옵션선택이 되어야하는거 아닌가?
|
||||
2. 그리고 나서 연결찾기의 방향이 결정될것같은데?
|
||||
3. 확인부터 해봐.
|
||||
|
||||
## 요구 718 (11-09 · 사용자 · 기계 줄 누르면 목록이 나옴 → 옵션 없어도 됨 · 문제는 연결을 못 찾는 것 · 조회어 「굴착기(무한궤도) 0.7㎥」 가 문제 아닌지 · 기계 못 연결하는 이유 다시 확인)
|
||||
|
||||
지금 확인했는데 좌측영역에서 기계 줄에서 지정된 기계를 눌렀더니 옵션 별로 항목 리스트가 나오는데 이러면 옵션이 없어도 되는거잖아. 그럼 문제는 옵션 여부가 아니라 연결을 제대로 찾지 못하는게 아닌가? 그리고 추가로 좌측영역 기계를 눌렀을때 굴착기(무한궤도) 0.7㎥ 이렇게 조회를 하는데 이게 문제 아니야? 기계를 못 연결하는 이유를 다시 확인해봐.
|
||||
|
||||
## 요구 719 (11-09 · 사용자 · 기계 예들 직접 찾아 보니 대형 브레이커 + 굴착기 빼고 다 찾을 수 있음 · 옛 이름은 별칭 사용 가능 · 조합 기계 = 설비 둘을 각각 가져오기)
|
||||
|
||||
너의 알려준 낱말을 모르는 예와 후보가 여럿인 예를 내가 직접찾아 봤는데 대형 브레이커 + 굴착기 만 제외하면 다 려었이 아니라 다찾을 수 있겠는데? 그리고 경쟁사 프로그램이 예전프로그램이라 찾기 힘들면 별칭을 사용해도됨. 다음으로 2개의 기계의 조합을 대현 브레이커, 굴착기로 표현한게 아닐까? 우리는 설비 2개를 각각 가져오면 맞을 것같다는 생각이 드네.
|
||||
|
||||
## 요구 720 (11-09 · 사용자 · 수정을 어떻게 요청했는지 물음)
|
||||
|
||||
뭐지 어떻게 수정을 요청한거야?
|
||||
|
||||
## 요구 721 (11-09 · 사용자 · 작업 이어서 · 기계 · 옵션 때문에 품셈 원문 제목과 다르게 표현된 단가산출 기초 조사 — 200 개쯤 예상 · 1,300 → 1,500 늘어난 까닭)
|
||||
|
||||
같은 기종인데 규격만 다른 경우은 지금의 수정으로 가능한거 아니야?
|
||||
|
||||
1. 작업은 이어서 진행, 문제는 없어보임.
|
||||
2. 우리의 단가산출은 기계나 특정 옵션에 의해 품셈 원문 제목과는 다르게 표현된 항목들이 거기에 해당 될것 같은데. 그것때문에 제대로 연결이 안되는게 아닐까? 어떤게 있는지 먼저 조사가 필요해봐. 그렇다 하더라도 품셈원문 제목에 품셈원문 제목이 있다면 모를까 하지만 제목에 기계가 들어가는 경우는 드물것같아. . 내가 예상하기로는 200개 정도 될것같은데 확인해봐. 이전에는 1300개정도에서 갑자기 1500개로 단가산출 기초가 늘었거든.
|
||||
|
||||
## 요구 722 (11-09 · 사용자 · 기초 이름 정리 방향 다섯 — 수정 말고 대답만 · 방향성부터)
|
||||
|
||||
1. 제목 + 옵션, 조건 덧말은 조심해야 원문제목과 대조할 필요가 있어보임
|
||||
2. 제목과 다르지만 분기가 필요해 셋으로 나눈건 괜찮음.
|
||||
3. 보조문서는 기계와 연계된 정보이니 단가산출이 아닌걸로 봐야하는게 아닌지. 자동으로 연결될 것같은데?
|
||||
4. 기계 이름이 품셈 제목에 없는데붙는 경우는 수정해야 될것같음.
|
||||
5. 위에처럼 정리하고 나면 이름으로 구분안되는 정보들이 나올텐데 이때는 삭제하는게 맞겠지?
|
||||
수정하지말고 대답해줘. 방향성 부터 봐야지.
|
||||
|
||||
## 요구 723 (11-09 · 사용자 · 방향 다섯 동의(3 은 둘 자리 협의) · 원문 패널 md 경로 삭제 · [주] 글자 크기 반대로 · 반영 · 다시 조사 · 작업 이어서 — sub3 과 충돌 없으면 다른 워크트리에)
|
||||
|
||||
1. 동의
|
||||
2. 동의
|
||||
3. 동의는 하나, 단가산출 기초 리스트에서 빼서 어디에 둘건지는 협의 필요
|
||||
4. 동의
|
||||
5. 동의
|
||||
추가로
|
||||
6. 지금 단가산출의 우측 공통 원문 패널보기 내용 중에 md파일의 경로를 알려주고 있음. 이부분은 다른 워크트리에게 경로를 볼필요없으니 삭제해달라고 해줘.
|
||||
7. 원문 패널의 내용 중 [주] 내용의 글자가 더크고 본문의 내용이 작음. 반대가 되는게 맞음.
|
||||
위 내용을 반영하고 다시 조사하고 작업을 이어서 하는데 너가 판단해보고 현재 작업중인 서브3과 충돌이 없다면 다른 워크트리에게 작업을 시키자.
|
||||
|
||||
## 요구 724 (11-09 · 사용자 · 보조 문서를 단가산출 목록에서 빼면 어디에 둘지 의견 물음)
|
||||
|
||||
단가산출 목록에서 빼면 어디다 두는게 좋을것같아?
|
||||
|
||||
## 요구 725 (11-09 · 사용자 · 보조 문서 자리 = 권장으로 해도 될 듯 · 조사해 맞는지 먼저 보고 판단 · 「정할 것」 설명 필요)
|
||||
|
||||
1. 권장으로 해도 될것같음. 하지만 조사해보고 맞는 먼저 보고 판단하자.
|
||||
2. 정할것에 대한 설명이 필요.
|
||||
|
||||
## 요구 726 (11-09 · 사용자 · 전용 작업량도 기계 항목으로 · 작업량당 기계경비 예시 요청 · 기계 무관 = 제안대로 · 토사절취(백호 0.7㎥) 대화창 출력 · 1~4 한 번에 진행 예정 — 일단 대기)
|
||||
|
||||
1. 전용 일지라도 결과적으로는 다른곳에서 사용할수도 있으니 기계라면 당연히 기계항목으로 들어가는게 맞아보임. 현재는 하나지만 나중에는 늘어날수도 있잖아.
|
||||
2. 작업량당 기계경비는 어떤걸말하는것임? 예를 들어 보여줘.
|
||||
3. 기계무관은 너의 제안대로
|
||||
4. 토사절취(백호 0.7㎥) 단가산출을 대화창에 출력해줘.
|
||||
5. 1~4번은 한번에 진행할 생각이야. 그러니 일단대기하고 4번을 대화창에 출력
|
||||
|
||||
## 요구 727 (11-09 · 사용자 · 토사절취 예시의 문제가 무엇인지 물음 — 시간당이 아니라 단위당으로 나오는 것이 문제냐)
|
||||
|
||||
이게 뭐가 문제야? 원래는 기계의 돈은 시간당으로 나와야하는데 단위면적당으로 나온다는 거야?
|
||||
|
||||
## 요구 728 (11-09 · 사용자 · 신규 단가산출에 기계 하나만 지정하면 작업량당 기계경비와 같은 것)
|
||||
|
||||
아니 그러니까 내가 만약에 신규 단가산출을 만들고 거기에 기계만 하나 지정하면 같은게 되는거잖아.
|
||||
|
||||
## 요구 729 (11-09 · 사용자 · 결국 표현 단위 차이 — 단위 환산 로직을 단가산출 기초에 두는 게 맞지 않나)
|
||||
|
||||
결국은 표현의 단위차이라는 말 아니야? 그렇다면 표현하는 단위 환산로직을 단가산출 기초에 넣어두는게 더 맍지 않아?
|
||||
|
||||
## 요구 730 (11-09 · 사용자 · 표로 다시 · 짧게)
|
||||
|
||||
테이블로 다시 표현해봐 . 이해하기 힘들어. 짧게 설명해줘.
|
||||
|
||||
## 요구 731 (11-09 · 사용자 · 나눔 맞는 것 같음 · 단가산출 기초 화면의 공식 틀 추가는 필요 없어진 것인지)
|
||||
|
||||
맞는것같네. 그럼 이제 단가산출 기초화면에서 공식 틀 추가는 필요없어진 것임?
|
||||
|
||||
## 요구 732 (11-09 · 사용자 · 「식」으로 변경 · 작업 진행 · 진행 뒤 엑셀 불러오기에서 더 결정할 것 알려 달라)
|
||||
|
||||
식으로 변경하자. 이제 작업 진행하자.
|
||||
진행 후 엑셀불러오기와 관련하여 뭐를 더 결정해야하는지 알랴줘.
|
||||
|
||||
## 요구 733 (11-09 · 사용자 · 결정 목록 여섯에 대한 답 · 물음 + 드래그 그룹 이동 확인 — 수정 아님 · 조사부터)
|
||||
|
||||
1. 이유 짧게 설명
|
||||
2. 규격이 다른 같으 기종의 경우 가져온 걸로 우리의 기계값을 바꾸면 되는거 아닌지.
|
||||
3. 반올림 자리만 다른 경우 계산에는 정밀도 높게 계산하는지 확인하고 계산에 문제 없다면 확정. 품숫자가 다른 4개라는건 뭔지 짧게 설명
|
||||
4. 작업량 당 기계경비의 경우에는 단가산출이 어떤식으로 제목이 나오는지 확인. 기계만 있는 단가산출도 필요는 해보이는데 문제는 우리의 단가산출은 자유롭게 기계를 변경할 수 있음. 그렇다면 만약 단가산출 기초 제목으로 M02에서 찾을텐데 이때 기계명이 있는 이름의 단가산출이 있다면 사용자는 해당 단가산출 정보만 있을것으로 오해할것이기 때문에 6개의 단가산출이 기계명으로만 구분되어 있는 단가산출이라면 그냥 통합도 가능할 것으로 예상되어 물어봄(수정하라는 말은 아님)
|
||||
5. 별칭 승인의 승인요청 시점이 사용자들이 저장하여 단가산출을 저장한 시점으로 보이는데 맞음? 그리고 별칭은 있어야 되는건 맞는데 혹시 경쟁사 프로그램의 출력물로 찾을 경우 이제 빈도가 줄었을 것으로 보임. 어느정도 수준인 확인
|
||||
6. 굴삭기 + 브레이카 는 사용자가 그룹 구분 없이 사용한다면 한 덩이로 그룹으로 구분한다면 자동으로 구분되겠지. 일반적으로는 구분되게 사용하는게 맞는것같음.
|
||||
7. 드래그로 그룹이동 되는지 확인
|
||||
바로 수정작업 아님. 조사부터 진행.
|
||||
|
||||
## 요구 734 (11-09 · 사용자 · 결정 답 — 소계는 우리 양식 · 수량 끝자리 뜻 물음 · 품 숫자 = 우리 값이 최신(가져오면 우리 값) · 작업량당 기계경비 단가산출 불필요 의견 물음 · 별칭 8 목록 요청(초기 별칭 직접 · 승인까지) · 6 · 7 문제 없음 · 1~5 한 번에 진행 예정 — 먼저 대답)
|
||||
|
||||
1. 소계는 결국 항목들의 합계형식일 뿐 우리양식 맞춰 표기해도 문제 없을것같다는 생각이 듬.
|
||||
2. 수량 끝자리라는게 뭐임?
|
||||
3. 품 숫자가 다르다는건 결국 품셈이나 요소들에 지정된 값이 다르다라는 의미지? 그렇다면 우리가 더 최신이 맞음. 그래서 가져오기 전 비교할때는 해당정보로 계산하겠지만 가져오면 우리값으로 업데이트 되는게 맞다고 대답한 적이 있음. 그리고 만약 그래도 사용자가 이전값으로 사용하겠다면 우리 단가산출 양식에는 값변경을 할수 있으니 OK
|
||||
4. 작업량당 기계경비는 결국 이걸 활용하여 다른 단가산출들에서 사용하고 있는거잖아. 기계로 넣어서 사용하고 있는거잖아. 그렇다면 작업량당 기계경비라는 단가산출을 만들어둘 필요가 없어보이네 너의 생각은? 어차피 경쟁사 데이터 불러오기 기능에도 우리 단가산출 표현에 문제도 없을거고 있으면 되려 혼란을 줄것같아서 말이야.
|
||||
5. 별칭 필요한 8개의 리스트를 보여주면 초기 별칭을 내가 넣어줄게. 승인처리까지 했으면 함.
|
||||
6. 문제 없음.
|
||||
7. 문제 없음.
|
||||
1 ~ 5번은 한번에 진행할거야. 가능한 경우에는 다른 워크트리를 사용해도됨. 그러니 먼저 대답부터
|
||||
|
||||
## 요구 735 (11-09 · 사용자 · 1~3 동의 · 작업량당 기계경비 = 필요하면 기계 하나 넣어 만들어 씀 · 별칭 아홉 답)
|
||||
|
||||
1. 동의
|
||||
2. 동의
|
||||
3. 동의
|
||||
4. 작업량당 기계경비는 본인이 필요한 경우 기계하나만 넣고 만들어 사용하면 됨.
|
||||
5. 별칭 확인
|
||||
1) 후보맞음.
|
||||
2) 나중에 내가 승인(어디 단가산출에 있는지 대화창에 출력)
|
||||
3) 후보맞음.
|
||||
4) 엔진(디젤) 이렇게 되어 있음. 가솔린은 엔진(가솔린)으로 되어 있음.
|
||||
5) 나중에 내가 승인(어디 단가산출에 있는지 대화창에 출력)
|
||||
6) 나중에 내가 승인(어디 단가산출에 있는지 대화창에 출력)
|
||||
7) 워터젯트
|
||||
8) 나중에 내가 승인(어디 단가산출에 있는지 대화창에 출력)
|
||||
9) 후보맞음
|
||||
|
||||
## 요구 736 (11-09 · 사용자 · 바이브레이터 규격이 하나면 엔진식)
|
||||
|
||||
바이브 레이터에 규격은 하나임? 하나라면 엔진식이 맞음.
|
||||
|
||||
## 요구 737 (11-09 · 사용자 · 남은 결정 사항 물음)
|
||||
|
||||
남은 결정사항은 뭐가 있어?
|
||||
|
||||
## 요구 738 (11-09 · 사용자 · 별칭 넷 = 단가산출 만들다 나오면 승인(할 일 없음) · 원문 패널 표 글자 16 · 제목 통합(그림 `요구738_원문패널_제목중복.png`) · 조합 기계 = 개별 그룹으로 · 공식 연결 분리 가능한지 조사 · 반영 뒤 단가산출 불러오기 정합성 확인)
|
||||
|
||||
지금 정할 수 있는 것.
|
||||
1. 지금 하겠다는게 아니라 단가산출을 만들면서 해당내용이 나오면 내가 승인하겠다는거야. 그러니 이제 너가 할 일은 없음.
|
||||
2 .표의 글자도 16으로 변경
|
||||
2-1. [Image #1]중복표기가 많음 제목을 통합해줘. 건설품셈 공통 7-1-1 메쌓기('12, '19, '26년 보완) 형태면 될것같음.
|
||||
위 내용도 같이 반영, 반영완료
|
||||
3. 조합기계는 우리 단가산출로 가져올때 개별 그룹으로 하나씩 가져오는게 좋을것같음.
|
||||
4. 공식을 연결해야 단가가 나올것같은데 분리할 수 없을것같은데 조사
|
||||
5. 위내용까지 반영 후 일단 단가산출에 대한 불러오기 정합성 확인
|
||||
|
||||
## 요구 739 (11-09 · 사용자 · 4 조사 결과 — 일단 현재 유지가 좋겠음 · 지금은 원문 기준으로 만든 품셈이 맞는지)
|
||||
|
||||
4 조가결과에서 일단 현재를 유지하는게 좋을것같은데 일단 현재는 원문을 기준으로 만들어진 품셈이잖아. 맞지?
|
||||
|
||||
## 요구 740 (11-09 · 사용자 · 현재 유지 범위 확인)
|
||||
|
||||
현재 유지는 지금의 범위가 맞음
|
||||
|
||||
## 요구 741 (11-10 · 사용자 · 정합성 · 연결 · 흠 · 정할 것을 아티팩트로)
|
||||
|
||||
불러오기 정합성, 우리기초와 연결, 찾음 흠, 정할것을 별도의 아티팩트로 저장.
|
||||
|
||||
## 요구 742 (11-10 · 사용자 · 엑셀 = 경쟁사 프로그램 결과물인데 왜 다른가 · 파일은 하나만 넣게 · STmate 단가산출표 = 산출근거 상단 제목 — 조사)
|
||||
|
||||
1.일단 하나만 물어보자. tool 폴더에는 경쟁사 복제프로그램을 만들어뒀어. 그렇다면 엑셀 파일은 이 경쟁사 프로그램에 의해 결정될텐데 서로 다르다라는게 이해가 되지않아서 말이야. 엑셀이라면 먼저 경쟁사 분석엔진을 돌고 그다음은 경쟁사 프로그램 확장자로 넣은 결과와 같다는 거지. 그럼 복수의 파일을 받는게 아니라 사용자는 엑셀이든 경쟁사 확장자 파일이든 하나만 넣게할거야.
|
||||
2. stmate 확장자인경우 단가산출표가 없다고 했는데 단가산출표는 산출근거의 상단제목만 뽑은게 단가산출표야.
|
||||
조사해봐
|
||||
|
||||
## 요구 743 (11-10 · 사용자 · 이제부터 불러오기는 방향 결정만 · 코드 수정은 한 번에 · 아티팩트 기준으로 하나씩 협의 · 1 동의 · 2 확장자 파일 = 단가산출표 + 산출근거만(일위대가 제외) · 3 이름 꼬리 = 연결용 분리면 문제없음)
|
||||
|
||||
이제부터 불러오기에 대한 방향성 결정만 하고 코드 수정은 한번에 진행할 생각임. 이전 대화에서 공유해준 아티팩트를 기준으로 하나하나 방향성을 협의하고 조사하고 방향성을 수립해두면 됨.
|
||||
1. 동의함.
|
||||
2. 경쟁사 확장자일 경우 단가산출표와 산출근거만 표현. 일위대가는 제외
|
||||
3. 엑셀이름 꼬리는 우리단가 연결을 위한 분리작업이라면 문제없음.
|
||||
|
||||
## 요구 744 (11-10 · 사용자 · B1 · B2 동의 · B3 동의 안 됨(우리 프로그램엔 있음 · 이름으로 따라가고 우리 단가산출화 때 조합으로) · C1 비목을 바꾸면 됨 · C2 품셈 밖이 아니라 품셈 기본으로 조합하고 이름만 넣은 자료 아닌지)
|
||||
|
||||
b1 b2 동의
|
||||
c3는 동의 안됨. 우리프로그램은 있음. 경쟁사 프로그램에 정보가 없다는말? 그렇다면 그냥 이름만 보고 따라간다면? 우리 단가산출화 했을때는 따로 조합으로 보려주면 될듯한데?
|
||||
c1 비못을 바꾸면 되지 않음?
|
||||
c2 품셈밖이 아니라 결국 품셈을 기본으로하여 조합하고 이름만 넣은 자료가 아닌지.
|
||||
|
||||
## 요구 745 (11-10 · 사용자 · B3 = 경쟁사 목록에 없다는 뜻인지(맞으면 제안 OK) · C2 = 목적은 우리 기초 만들기가 아니라 서로의 인력/자재/기계/다른 정보로 단가산출을 만드는 것 — 특정 기초와 맞추기보다 우리 정보로 만들어 낼 수 있는지)
|
||||
|
||||
b3 에서 경쟁사 목록에 해당내용이 없다는건지? 그게 맞다면 제안이 맞음
|
||||
c2 지금 우리가 하는 작업은 우리단가산출 기초를 만들기 위함이 아님. 우리의 단가산출 기초처럼 서로의 인력/자재/기계/다른 정보로 단가산출을 만들기 위함임. 그러니 우리의 특정단가산출 기초와 동일하게 맞추기보다는 우리의 정보로 만들어 낼수 있는지를 봐야함.
|
||||
|
||||
## 요구 746 (11-10 · 사용자 · B3 = 품셈 원문에 없어서 안 만든 것인지 · B2 맞는 방향)
|
||||
|
||||
b3 그럼 품셈 원문에 해당 내용이 없어서 아뉴만들었다는게 되는건데 맞아?
|
||||
b2 맞는 방향인것으로 판단됨
|
||||
|
||||
## 요구 747 (11-10 · 사용자 · B3 = 원문에 있는데 누락된 것만 추가하면 되는지 · 다음 = 유가가 왜 연결 안 되는지 조사)
|
||||
|
||||
b3 품셈 원문에 있는데 누락된 것만 추가하면 된다는거지?
|
||||
다음 유가 부분이 왜 연결안되는지 조사
|
||||
|
||||
## 요구 748 (11-10 · 사용자 · 유가 = 전국으로 · 전력 = 토목공사에 맞는 기본값으로 결정 · 이상하면 사용자가 나중에 바꿈)
|
||||
|
||||
유가는 경쟁사는 분리안되어있다는 말인것같고 일단 전국으로 해줘. 나중에 필요하면 변경하겠지. 전력도 어떤 항목이 토목공사에 더 작합한지 고민해보고 그냥 기본값으로 결정하면 됨. 이후 둘다 이상하면 사용자가 나중에 변걍할거야
|
||||
|
||||
## 요구 749 (11-10 · 사용자 · 재료 안 맞음 · 화면 속도는 나중에 한 번에 · 결정 사항 수정 정리 + 예상 정합성 조사)
|
||||
|
||||
일단은 맞는것같아.
|
||||
화면속도는 나중에 한번에 진행항 생각.
|
||||
이제까지 결정사항에 대한 수정사항을 정리해보고 어느정도 정합성이 나올건지 조사해줘
|
||||
꽤 많이 개선된걸로 보임
|
||||
|
||||
## 요구 750 (11-10 · 사용자 · 진행)
|
||||
|
||||
진행
|
||||
|
||||
## 요구 751 (11-10 · 사용자 · 다른 워크트리와 나눠서 작업하면 나을지 물음)
|
||||
|
||||
다른 워크트리와 나눠서 작업하면 좀 나을려나?
|
||||
|
||||
## 요구 752 (11-10 · 사용자 · 혼자(한 창)로 하는 게 좋겠음 · 기다림)
|
||||
|
||||
그냥 혼자하는게 좋겠네. 기다릴께
|
||||
|
||||
## 요구 753 (11-10 · 사용자 · 다음 협의 1 · 2 번 설명 요청 · 재확인 = 우리 기초와 맞추기가 아니라 우리 기능 · 요소로 단가산출을 만드는 것)
|
||||
|
||||
1번 2번 설명.
|
||||
잘 이해해야하는 부분은 우리의 일위대가 기초와 동일하게 맞추는게 아니라 우리가 가지고 있는 기능과 요소로 단가산출을 만드는 거야. 이해하지? 이전 대화에서 했던 말이야. 재확인차 물어봄.
|
||||
|
||||
## 요구 754 (11-10 · 사용자 · 재료 지역 = 우리 재료가 지역별로 구분되어 있으니 재료를 바꾸면 되지 않나)
|
||||
|
||||
추가항목 지역관련해서는 우리 재료자체가 지역 구분되어있어 재료를 변경하면 문제없을 것 어떻게 생각함?
|
||||
|
||||
## 요구 755 (11-11 · 사용자 · 결정 전 확인 — 재료 DB 는 EST 정보 + 나라장터 정보로 만듦 · 그렇다면 EST 는 문제없지 않나)
|
||||
|
||||
결정하기전에 하나더 확인. 현재 재효 db는 est에서 가져온 정보와 나라잩터에서 가져온 정보를 추가하여 사용하고 있음. 그렇다면 est는 문제없을것같은데 어때?
|
||||
|
||||
## 요구 756 (11-11 · 사용자 · 계속 대화 — 1 EST 원래 DB 와 키가 같은지(별도 DB · 뒤에서 참조 키) · 2 EST DB 가 있는데 EST 출력 자료를 못 찾는 게 이해 안 됨 · 3 실무 엑셀 · 원파일 작업하며 DB 에 넣은 자료가 있는지)
|
||||
|
||||
계속 대화해보자.
|
||||
1. est원래db 와 완전히 키값이 같다는 말인건가? 복베문제도 있으니 별도의 db로 만들려고 했었는데 물런 뒤에서는 참조로 키값을 가지고 있어. 연결정보를 가지고 있으면 좋겠지만 말이야.
|
||||
2. 이해안되는 부분이 원래 est db도 있는데 est 프로그램에서 출력된 자료를 못 찾는게 이해가 안되네.
|
||||
3. 추가로 혹시 실무문서 엑셀, 원파일로 작업하면서 db에 넣은 자료가 있다는 말이야?
|
||||
|
||||
## 요구 757 (11-11 · 사용자 · 1 EST 출력 엑셀을 넣으면 분석 엔진으로 뭔지 알 수 있는 것 아닌지 · 2 경쟁사 두 DB 의 재료를 매칭해 중복을 줄이고 우리 재료 DB 에 서로의 키를 연결 정보로 갖는 안)
|
||||
|
||||
1. est출력 엑셀의 내용을 넣었을때 분석엔진을 통해 뭔지 알수 있는거 아니야?
|
||||
2. 경쟁사 2개의 db에서 재료의 매칭을 해서 중복을 최소화하고 우리 재료 db 에 서로의 키값을 연결 종보를 가지고 있는다면 좋을것같은데 어떻게 생각함?
|
||||
|
||||
## 요구 758 (11-11 · 사용자 · 공사가 직접 넣은 품목 = 그대로 유지해 단가산출 · 일위대가에 넣고, 만들어진 M02 단가산출 · 일위대가에서 바꾸도록 유도 — 추가 검토)
|
||||
|
||||
그 공사가 직접 넣은 품목은 그대로 유지해서 단가산출이나 일위대가로 넣어주되 만들어진 m02 단가산출/일위대가에서 해당정보를 변경하게 유도하는건 어떤지 추가 검토
|
||||
|
||||
## 요구 759 (11-11 · 사용자 · 3번(공사 추가 품목 유도)은 최종적으로 안 될 때 쓰는 방식 — 미룸 · 지금까지 대화에서 수정할 것 짧게 정리)
|
||||
|
||||
결국 3번은 최종적으로 안될경우 사용하는 방식이야. 일단은 미뤄두는게 좋음.
|
||||
일단 어떤게 수정할지 현재까지 대화내용을 짧게 작선해서 보여줘
|
||||
|
||||
## 요구 760 (11-11 · 사용자 · 후보 고르기도 추가하면 예측 정확도 예상 수치)
|
||||
|
||||
후보고르기도 추가된다면 예측정확도의 예상추치가 어떻게됨?
|
||||
|
||||
## 요구 761 (11-11 · 사용자 · 화면 속도 보류 · 우리 자재표 보강도 진행한다면)
|
||||
|
||||
화면속도는 보류, 우리 자재표 보강도 진행한다면?
|
||||
|
||||
## 요구 762 (11-11 · 사용자 · 1 기초 찾기 후보가 필요한지(요소 조합으로 만들기로 함) · 2 별칭 승인 리스트 전부 지움(이전 자료 연결 요청 · 오지정 가능) · 3 견적 정보 = 프로젝트 안에서 지정하는 금액 — 그대로 보여 주고 우리 정보로 안 바꾸면 그 가격 · 바꾸면 우리 값)
|
||||
|
||||
1. 단가산출근거의 기초 찾기 후보가 있을필요가 있음? 어차피 요소들을 조합해서 만들기로 했잖아.
|
||||
2. 일단 별칭 승인 리스트는 전부 지야함. 이전 자료에서 연결요청했던 내용이고 오지정이 있을 수 있음.
|
||||
3. 견적정보는 그때 그때 협력사에 받는 정보로 프로젝트 내부에서나 지정하는 금액임. 그러니 단가산출로 만들때는 해당정보를 그냥 보여주고 사용자가 우리 정보 지정없이 프로젝트에 사용한다면 그가격을 넣고 우리 정보로 변경했다면 문제 없는거고
|
||||
|
||||
## 요구 763 (11-11 · 사용자 · 추가 검토 = 별칭 승인이 웹앱 별칭에 들어가는지 · 수기 입력하면 반영되는지 · 1 기초 찾기 후보는 사본으로 바꾸기도 의미 없음(값 업데이트 됨 · 자원 소모) · 2 일단 동의 · 3 견적은 나중에 못 찾는 정보에도 비슷하게 · 자재가 있으면 견적 대신 우리 DB · 프로젝트 안에서 따로 지정 예정)
|
||||
|
||||
추가 검토 내용
|
||||
별칭이 승인이 된다면 웹앱의 별칭에 해당정보가 입력이 되는지 그리고 만약 내가 별칭에 수기 입력늘 한다면 반영이 되는지 확임
|
||||
1. 기초찾기 후보는 사본으로 바꾸기도 의미없어보임. 어차피 값 업데이트도 되고 찾는데 자원소모만 됨.
|
||||
2. 일단 동의
|
||||
3.결국 견적의 기능은 나중에 못찾는 정보들도 유사하게 반영이 될거야. 아직보류했지만. 반대로 자재가 있는 경우에도 견적정보가 들어오면 견적정보는 필요없고 우리 db정보로 대체하면 됨. 나중에 프로젝트 내부에서 별도로 지정할 생각.
|
||||
|
||||
## 요구 764 (11-11 · 사용자 · 별칭 열은 한 열로만 운영 · 콤마로 여러 개 추가 · 반영해 작업 리스트 짧게)
|
||||
|
||||
별칭열은 한 열로만 운영. 대신 구분기호 콤마를 사용하여 추가할수 있게 변경.
|
||||
위내용 반영하여 작업리스트를 짧게 표기
|
||||
|
||||
## 요구 765 (11-11 · 사용자 · 옛 별칭 장부 걷기 가능 · M01 별칭은 승인 여부나 별칭이 적힌 것만 모아 볼 수 있게(수기 포함) · 남은 공사 내역 맞춤도 지움 · 진행 — 한 워크트리 또는 적당히 분리(토큰 조심))
|
||||
|
||||
1. 옛 별칭장부를 걷는건 가능. 하지만 M01 별칭은 승인 후 여부나 별칭이 작성된 것만 모아서 볼수 있게 반영하는게 어떤지. 없어진다면 각 요소들과 컨테이너를 접근하여 하나하나 확인해야함. (수기로 넣더라도 보이게 해야함.)
|
||||
2. 남은 공사내역 맞춤도 일단 지워줘.
|
||||
진행할 때 필요한 경우에는 하나의 워크트리에서 진행가능. 하지만 분리하는게 유리하다면 적당히 분리하여 다른 워크트리에게 지시하는것도 검토. (너무 많이 분리하면 토큰 소모 심해짐 조심.)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -549,10 +549,9 @@ app.include_router(b09_estimation_router, dependencies=protected_with_company)
|
||||
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
|
||||
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
|
||||
app.include_router(m01_master_data_router, dependencies=system_admin_only)
|
||||
app.include_router(
|
||||
__import__("M01_MasterData.M01_MasterData_Alias", fromlist=["router"]).router,
|
||||
dependencies=system_admin_only,
|
||||
) # 별칭 장부 모으기(718-2)
|
||||
_m01_alias = __import__("M01_MasterData.M01_MasterData_Alias", fromlist=["router"])
|
||||
app.include_router(_m01_alias.router, dependencies=system_admin_only) # 별칭 모아 보기(815-2)
|
||||
app.include_router(_m01_alias.suggest_router, dependencies=protected) # 별칭 제안 — 로그인만
|
||||
# 단위 · 금액 표준 표 읽기(`GET /api/m02/df/standards` · 단위 고르기 `M02_DF_UnitPick`) — 읽기만 · 로그인만(M02 줄 쌓기 단위)
|
||||
app.include_router(
|
||||
__import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router,
|
||||
|
||||
@@ -118,3 +118,14 @@
|
||||
- 끝수 결과 · 중간 `표시` → 식 안 `반올림` · 끝수 계 · 끝전 · 단계 · 조정 · 번지 · 합밖 · 자리 → 문서 칸 그대로.
|
||||
- 기호 = 수식 탭 기호(800-1 · 800-5) · 한 문서 안 겹침 = 아래첨자 끝 숫자.
|
||||
- 저장 = 대조(기본 옵션 + 옵션 표본)가 모두 같고 부르는 단가산출도 저장되는 문서만 · 다른 것 = `docs/raw/요구_1104/_목록_802-2_변환대조.xlsx`.
|
||||
|
||||
## 9. 부품 표지(813-2) — 단가산출이 아닌 보조 문서
|
||||
|
||||
- 머리 칸 `부품` = `{갈래, 기종?, 규격?}` · 옛 로직 줄 `부품` 을 변환이 그대로 옮김 · 셈 · 부르기 · 키로 열기는 그대로.
|
||||
- 단가산출 기초 목록 · 찾아 넣기 「다른 단가산출」 목록에 안 보임(`M01_MasterData_Parts.strip`).
|
||||
|
||||
| 갈래 | 뜻 | 칸 | 자리 |
|
||||
| --- | --- | --- | --- |
|
||||
| 기계경비식 | 기계 시간당 경비 셈 방식(기계 줄이 부름) | — | M01 「기계」 기종 아래 |
|
||||
| 작업량 틀 | 기종 공식(Q = 3,600 × q × K × f × E ÷ Cm 등) | `기종` = 기계표 상세구분 목록 · `규격` = 기계 규격 수를 받는 변수 열쇠 | M01 「기계」 기종 아래 |
|
||||
| 계수식 | 기계 무관 · 돈 아닌 식 | — | M01 「계수」 아래 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 337, "CC": 528, "GC": 1195, "QC": 2060, "GX": 5, "UA": 38},
|
||||
"폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}},
|
||||
"폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}, "GC001145": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)", "GC001148": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)", "GC001151": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)", "GC001156": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)", "GC001159": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)", "GC001166": "작업량당 기계경비 — 부르는 곳 0 · 필요하면 단가산출에 기계 하나 넣어 만듦(요구 734-4 · 735-4 · 813-2)"},
|
||||
"키": {
|
||||
"CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"},
|
||||
"CC000002": {"원문번호": "공통 1-3-1 노상 및 노반재료", "파일": "계수_건설품셈_01장_적용기준.json"},
|
||||
@@ -2490,28 +2490,22 @@
|
||||
"GC001142": {"원문번호": "공통 1-2-7 벽돌운반", "파일": "로직_건설품셈_01장_적용기준.json"},
|
||||
"GC001143": {"원문번호": "공통 8-1-4 기본식", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001144": {"원문번호": "공통 8-2-1 불도저", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001145": {"원문번호": "공통 8-2-1 불도저경비", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001146": {"원문번호": "공통 8-2-2 리퍼", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001147": {"원문번호": "공통 8-2-3 굴착기", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001148": {"원문번호": "공통 8-2-3 굴착기경비", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001149": {"원문번호": "공통 8-2-4 트랜처", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001150": {"원문번호": "공통 8-2-5 로더", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001151": {"원문번호": "공통 8-2-5 로더경비", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001152": {"원문번호": "공통 8-2-6 스크레이퍼", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001153": {"원문번호": "공통 8-2-7 그레이더면적", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001154": {"원문번호": "공통 8-2-7 그레이더토량", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001155": {"원문번호": "공통 8-2-8 덤프트럭", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001156": {"원문번호": "공통 8-2-8 덤프트럭경비", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001157": {"원문번호": "공통 8-2-9 롤러토량", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001158": {"원문번호": "공통 8-2-9 롤러면적", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001159": {"원문번호": "공통 8-2-9 롤러경비", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001160": {"원문번호": "공통 8-2-10 아스팔트플랜트", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001161": {"원문번호": "공통 8-2-11 스테이빌라이저", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001162": {"원문번호": "공통 8-2-12 죠", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001163": {"원문번호": "공통 8-2-12 롤", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001164": {"원문번호": "공통 8-2-12 이동식", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001165": {"원문번호": "공통 8-2-12 스크린", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001166": {"원문번호": "공통 8-2-13 브레이커경비", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001167": {"원문번호": "공통 8-2-14 압쇄기", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001168": {"원문번호": "공통 8-2-15 법면다짐기", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
"GC001169": {"원문번호": "공통 8-2-16 골재세척", "파일": "로직_건설품셈_08장_건설기계.json"},
|
||||
@@ -2791,16 +2785,16 @@
|
||||
"GF000249": {"원문번호": "9-14-2 다짐", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000250": {"원문번호": "9-15-2 표토제거 답외구간", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000251": {"원문번호": "9-17-2 비다짐", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000252": {"원문번호": "9-4-1 암파쇄(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000253": {"원문번호": "9-5-2 발파암 깎기 90%(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000252": {"원문번호": "9-4-1 암파쇄", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000253": {"원문번호": "9-5-2 발파암 깎기(90%)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000254": {"원문번호": "9-5-1 육상 암석 절취(발파 10%)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000255": {"원문번호": "9-6-1 발파암 기계소할(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000256": {"원문번호": "9-7-1 무근콘크리트 깨기(T=30㎝ 미만) 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000257": {"원문번호": "9-7-2 무근콘크리트 깨기(T=30㎝ 이상) 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000258": {"원문번호": "9-8-1 철근콘크리트 깨기(T=30㎝ 미만) 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000259": {"원문번호": "9-8-2 철근콘크리트 깨기(T=30㎝ 이상) 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000260": {"원문번호": "9-10-1 기존포장 깨기 콘크리트 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000261": {"원문번호": "9-10-2 기존포장 깨기 아스팔트 깨기(대형브레이커 · 굴착기 0.7㎥)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000255": {"원문번호": "9-6-1 발파암 기계소할(15%)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000256": {"원문번호": "9-7-1 무근콘크리트 깨기(T=30㎝ 미만)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000257": {"원문번호": "9-7-2 무근콘크리트 깨기(T=30㎝ 이상)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000258": {"원문번호": "9-8-1 철근콘크리트 깨기(T=30㎝ 미만)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000259": {"원문번호": "9-8-2 철근콘크리트 깨기(T=30㎝ 이상)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000260": {"원문번호": "9-10-1 기존포장 깨기 콘크리트", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000261": {"원문번호": "9-10-2 기존포장 깨기 아스팔트", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000262": {"원문번호": "9-11-1 포장절단 콘크리트(기계)", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000263": {"원문번호": "9-12-2 측구터파기 암절취", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
"GF000264": {"원문번호": "9-12-3 측구터파기 발파암", "파일": "로직_산림품셈_09장_토공.json"},
|
||||
@@ -2822,13 +2816,13 @@
|
||||
"GF000280": {"원문번호": "10-6-2 인력운반 돌(지게)", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000281": {"원문번호": "10-6-3 인력운반 기타 임업자재", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000282": {"원문번호": "10-8-1 케이블 크레인 짐내리기", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000283": {"원문번호": "10-9-1 덤프트럭 자재 운반(1회 표준 운반량)", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000284": {"원문번호": "10-12-1 덤프운반(굴착기 0.7㎥ 적재 · 덤프트럭 15ton)", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000283": {"원문번호": "10-9-1 덤프트럭 운반(자재의 1회당 표준 운반량)", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000284": {"원문번호": "10-12-1 덤프운반 토사", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000285": {"원문번호": "10-13-2 묘목 인력운반", "파일": "로직_산림품셈_10장_자재․장비_운반.json"},
|
||||
"GF000286": {"원문번호": "4-1-1 수확베기 임업용 동력기계톱 벌목", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000287": {"원문번호": "4-2-1 단목베기(100본당)", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000288": {"원문번호": "4-2-2 단목베기(1,000㎡당 · 굴착기 0.2㎥ + 부착용 집게)", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000289": {"원문번호": "4-3 위험목 베기(굴착기 우드그랩 0.2㎥ · 크레인 5톤)", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000288": {"원문번호": "4-2-2 단목베기(1,000㎡당)", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000289": {"원문번호": "4-3 위험목 베기", "파일": "로직_산림품셈_04장_나무베기.json"},
|
||||
"GF000290": {"원문번호": "5-1-1 관목굴취", "파일": "로직_산림품셈_05장_식재_및_녹화.json"},
|
||||
"GF000291": {"원문번호": "5-3-2 관목식재(단식)", "파일": "로직_산림품셈_05장_식재_및_녹화.json"},
|
||||
"GF000292": {"원문번호": "5-3-3 관목식재(군식)", "파일": "로직_산림품셈_05장_식재_및_녹화.json"},
|
||||
@@ -2860,7 +2854,7 @@
|
||||
"GF000318": {"원문번호": "8-1-2 나무주사 약제주입병", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000319": {"원문번호": "8-4 끈끈이 롤 트랩 설치·제거", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000320": {"원문번호": "8-6-2 드론방제(무인헬기 · 멀티콥터)", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000321": {"원문번호": "8-6-3 지상방제 차량살포(동력분무기)", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000321": {"원문번호": "8-6-3 지상방제 차량살포", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000322": {"원문번호": "8-10 그물망 피복", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000323": {"원문번호": "8-11 이동식 임목 파쇄(93.25KW)", "파일": "로직_산림품셈_08장_방제.json"},
|
||||
"GF000324": {"원문번호": "13-4-6 밑돌 및 천단 돌쌓기", "파일": "로직_산림품셈_13장_구조물.json"},
|
||||
|
||||
@@ -26,6 +26,7 @@ def marine(C) -> list[dict]:
|
||||
)
|
||||
row.pop("결과")
|
||||
row.pop("시험입력")
|
||||
row["이름"] = "수중펌프 설치 및 해체" # 813-2 원문 제목 · 윗절
|
||||
row["호표"] = [
|
||||
{
|
||||
"종류": "인력",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""시간당 경비 계산식(GC000267 ~ GC000276) 「기계」 고르기 · 선택 목록에 원문에 있는데 빠진 기계 더함(PLAN 814-1 · 요구 744 ~ 747).
|
||||
|
||||
기계표(EQ) 가운데 어느 계산식 목록에도 없는 기계를 자료(기계표 값)에 따라 식 하나에 배정 — 지어 넣는 값 없음:
|
||||
연료(주연료 · 종류) · 조종원(인 · 직종) · 잡재료율 · 상각 · 정비 · 관리 계수 있음 = GC000268(기계경비)
|
||||
연료 · 조종원(인) 있음 · 직종 없음 = GC000275(조종원직종 입력)
|
||||
연료 있음 · 조종원 없음 = GC000272(조종원 없는 기종)
|
||||
연료 없음 · 조종원(인 · 직종) 있음 = GC000276(연료 없는 기종)
|
||||
연료 · 조종원(직종) 없음 · 가격 · 손료계수 있음 = GC000273(손료만 — 부착 장비 등)
|
||||
그 식이 부르는 기계표 값이 비거나 수 하나가 아니면(「13∼18」) 안 넣고 「못 넣음」 목록에 적음.
|
||||
8-1-7 손료 보정 식(GC000269 · GC000274)은 보정 기종 식에 그 기계 번호가 이미 있을 때만 같이 더함.
|
||||
목록 끝에 덧붙임(기본값 그대로 = 기존 문서 금액 같음). 로직 파일(`로직_…_건설기계.json` 입력 고르기)과
|
||||
수식 문서(`수식문서/수식_…_건설기계.json` 「기계」 선택 목록)를 같이 고침. 두 번 돌려도 같음.
|
||||
|
||||
쓰기: venv python resources/master_data/scripts/build_기계목록_빠진기계_814_1.py [--check]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
DATA = HERE.parent
|
||||
LOGIC = DATA / "로직_건설품셈_08장_건설기계.json"
|
||||
DOC = DATA / "수식문서" / "수식_건설품셈_08장_건설기계.json"
|
||||
FAMILY = [f"GC00027{i}" for i in range(6)] + ["GC000267", "GC000268", "GC000269"]
|
||||
FAMILY = sorted(set(FAMILY) | {"GC000276"})
|
||||
FIX = ("상각비계수", "정비비계수", "관리비계수")
|
||||
NEEDS = {
|
||||
"GC000268": ("가격", *FIX, "주연료종류", "주연료", "조종원직종", "조종원", "잡재료율"),
|
||||
"GC000275": ("가격", *FIX, "주연료종류", "주연료", "조종원", "잡재료율"),
|
||||
"GC000272": ("가격", *FIX, "주연료종류", "주연료", "잡재료율"),
|
||||
"GC000276": ("가격", *FIX, "조종원직종", "조종원"),
|
||||
"GC000273": ("가격", "손료계수"),
|
||||
}
|
||||
PAIR = {"GC000268": "GC000269", "GC000273": "GC000274"} # 8-1-7 보정 식
|
||||
|
||||
|
||||
TEXT = ("주연료종류", "조종원직종") # 글 칸 — 그 밖 = 수 하나(범위 「13∼18」 · 빈 값 = 없음)
|
||||
|
||||
|
||||
def _has(row: dict, field: str) -> bool:
|
||||
v = (row.get("값") or {}).get(field)
|
||||
if v in (None, ""):
|
||||
return False
|
||||
if field in TEXT:
|
||||
return bool(str(v).strip())
|
||||
try:
|
||||
return float(str(v).replace(",", "")) != 0
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def family_of(row: dict) -> tuple[str | None, list[str]]:
|
||||
"""기계표 한 줄 → (식 키 | None, 빈 값 이름들) — 연료 · 조종원 유무로 식 가족을 고름."""
|
||||
fuel, crew, job = _has(row, "주연료"), _has(row, "조종원"), _has(row, "조종원직종")
|
||||
if fuel and crew:
|
||||
key = "GC000268" if job else "GC000275"
|
||||
elif fuel:
|
||||
key = "GC000272"
|
||||
elif crew and job:
|
||||
key = "GC000276"
|
||||
else:
|
||||
key = "GC000273"
|
||||
miss = [f for f in NEEDS[key] if not _has(row, f)]
|
||||
return (None if miss else key), miss
|
||||
|
||||
|
||||
def _rows() -> list[dict]:
|
||||
data = json.loads((DATA / "기계.json").read_text("utf-8"))
|
||||
return [
|
||||
r for r in data.get("줄") or data.get("표") or [] if str(r.get("키", "")).startswith("EQ")
|
||||
]
|
||||
|
||||
|
||||
def _logic_lists(text: str) -> dict[str, list[str]]:
|
||||
data = json.loads(text)
|
||||
out = {}
|
||||
for row in data["줄"]:
|
||||
for i in row.get("입력") or []:
|
||||
if row.get("키") in FAMILY and i.get("이름") == "기계":
|
||||
out[row["키"]] = [str(c) for c in i.get("고르기") or []]
|
||||
return out
|
||||
|
||||
|
||||
def _span(text: str, key: str, start_pat: str) -> tuple[int, int]:
|
||||
"""그 키 블록 안 첫 `start_pat` 뒤 배열 [ … ] 의 (여는 괄호, 닫는 괄호) 자리."""
|
||||
at = text.index(f'"키": "{key}"')
|
||||
m = re.compile(start_pat).search(text, at)
|
||||
if not m:
|
||||
raise SystemExit(f"{key} 기계 목록 자리를 못 찾음")
|
||||
open_ = m.end() - 1
|
||||
close = text.index("]", open_)
|
||||
return open_, close
|
||||
|
||||
|
||||
def _append(text: str, key: str, codes: list[str], start_pat: str) -> str:
|
||||
if not codes:
|
||||
return text
|
||||
open_, close = _span(text, key, start_pat)
|
||||
body = text[open_ + 1 : close]
|
||||
if "\n" in body: # 여러 줄 꼴 — 마지막 줄 들여쓰기 그대로
|
||||
last = body.rstrip().rsplit("\n", 1)[-1]
|
||||
pad = last[: len(last) - len(last.lstrip())]
|
||||
add = "".join(f',\n{pad}"{c}"' for c in codes)
|
||||
cut = open_ + 1 + len(body.rstrip())
|
||||
return text[:cut] + add + text[cut:]
|
||||
add = "".join(f', "{c}"' for c in codes)
|
||||
return text[:close] + add + text[close:]
|
||||
|
||||
|
||||
LOGIC_PAT = r'"이름":\s*"기계",\s*"고르기":\s*\['
|
||||
DOC_PAT = r'"열쇠":\s*"기계"[^\n]*?"목록":\s*\['
|
||||
|
||||
|
||||
def plan() -> tuple[dict[str, list[str]], list[tuple[str, str, str, list[str]]]]:
|
||||
logic = LOGIC.read_text("utf-8")
|
||||
have = _logic_lists(logic)
|
||||
taken = {c for v in have.values() for c in v}
|
||||
fixes = {}
|
||||
for r in json.loads(logic)["줄"]:
|
||||
if r.get("키") in PAIR.values():
|
||||
fixes[r["키"]] = " ".join(str(x.get("식") or "") for x in r.get("중간") or [])
|
||||
add: dict[str, list[str]] = {}
|
||||
skipped = []
|
||||
for row in _rows():
|
||||
code = str(row.get("원문번호") or "")
|
||||
if not code or code in taken:
|
||||
continue
|
||||
key, miss = family_of(row)
|
||||
if key is None:
|
||||
skipped.append((code, str(row.get("이름")), str(row.get("규격") or ""), miss))
|
||||
continue
|
||||
if code in add.get(key, []):
|
||||
continue
|
||||
add.setdefault(key, []).append(code)
|
||||
if PAIR.get(key) and f"'{code}'" in fixes.get(PAIR[key], ""):
|
||||
add.setdefault(PAIR[key], []).append(code)
|
||||
return add, skipped
|
||||
|
||||
|
||||
def main(check: bool = False) -> None:
|
||||
add, skipped = plan()
|
||||
print("더함:", {k: len(v) for k, v in sorted(add.items())}, "· 못 넣음:", len(skipped))
|
||||
for code, name, spec, miss in skipped:
|
||||
print(f" 못 넣음 {code} {name} {spec} — 빈 값 {', '.join(miss)}")
|
||||
if check or not add:
|
||||
return
|
||||
logic, doc = LOGIC.read_text("utf-8"), DOC.read_text("utf-8")
|
||||
for key, codes in sorted(add.items()):
|
||||
logic = _append(logic, key, codes, LOGIC_PAT)
|
||||
doc = _append(doc, key, codes, DOC_PAT)
|
||||
json.loads(logic), json.loads(doc) # 꼴 확인
|
||||
LOGIC.write_text(logic, "utf-8", newline="")
|
||||
DOC.write_text(doc, "utf-8", newline="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main("--check" in sys.argv)
|
||||
@@ -30,8 +30,8 @@ REGIONS = [
|
||||
] # fmt: skip
|
||||
ROCK = ["암석작업(연암・보통암・경암)", "해당 없음", "전석섞인토사"]
|
||||
NAMES = (
|
||||
"시멘트운반(구역화물 운임 + 하차비)",
|
||||
"철근운반(구역화물 운임 + 하차비)",
|
||||
"시멘트운반",
|
||||
"철근운반",
|
||||
"깬잡석 · 야면석 · 고임돌 · 파쇄석 채집(기계)",
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ def haul(no: str, what: str, unit: str, per: str, old: str) -> dict:
|
||||
div = "1.1 * 적재량 * 포수" if sack else "1.1 * 적재량"
|
||||
return {
|
||||
"원문번호": no,
|
||||
"이름": f"{what}(구역화물 운임 + 하차비)",
|
||||
"이름": what,
|
||||
"결과단위": unit,
|
||||
"출처": f"산림품셈 {no}",
|
||||
"소유": "공용",
|
||||
|
||||
@@ -375,7 +375,7 @@ def logics(key: dict) -> list[dict]:
|
||||
"키": None,
|
||||
"열쇠": "공통 1-2-7 인력운반(지게)",
|
||||
**HEAD,
|
||||
"이름": "인력운반(지게) — 건설 공통 1-2-7",
|
||||
"이름": "지게운반",
|
||||
"결과단위": "원/자재 단위",
|
||||
"출처": f"{SRC} 2 · 3",
|
||||
"소유": "공용",
|
||||
@@ -461,7 +461,7 @@ def logics(key: dict) -> list[dict]:
|
||||
"키": None,
|
||||
"열쇠": "공통 1-2-7 인력운반(기계설비)",
|
||||
**HEAD,
|
||||
"이름": "인력운반(기계설비) — 건설 공통 1-2-7",
|
||||
"이름": "인력운반(기계설비)",
|
||||
"결과단위": "원/자재 단위",
|
||||
"출처": f"{SRC} 5",
|
||||
"소유": "공용",
|
||||
@@ -534,7 +534,7 @@ def logics(key: dict) -> list[dict]:
|
||||
"키": None,
|
||||
"열쇠": "공통 1-2-7 벽돌운반",
|
||||
**HEAD,
|
||||
"이름": "벽돌운반 — 건설 공통 1-2-7",
|
||||
"이름": "벽돌운반",
|
||||
"결과단위": "원/1,000매",
|
||||
"출처": f"{SRC} 4",
|
||||
"소유": "공용",
|
||||
|
||||
@@ -164,12 +164,7 @@ def main() -> None:
|
||||
C = chooser(made, old_coef)
|
||||
|
||||
rows = L1.part1(C) + L2.part2(C) + L3.part3(C)
|
||||
q_rows = {r["열쇠"]: r for r in rows}
|
||||
hourly = {r["키"]: r for r in _load(LOGIC)["줄"]}
|
||||
machines: dict[str, list] = {}
|
||||
for m in _load("기계.json")["줄"]:
|
||||
machines.setdefault(m.get("상세구분"), []).append(m["원문번호"])
|
||||
rows += LC.costs(q_rows, hourly, machines) + [LC.breaker(hourly)]
|
||||
# 작업량당 기계경비(LC.costs · LC.breaker) = 813-2 지움 — 단가산출에 기계 하나 넣어 만듦
|
||||
rows.sort(key=lambda r: _sec(r["원문번호"]))
|
||||
for r in rows:
|
||||
r["키"] = mk.issue(book, "GC", r["열쇠"], LOGIC)
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
· 계(돈 아닌 로직 = 결과)
|
||||
원 단위 — 기본 옵션 전부 + 옵션 표본(로직마다 고르기 입력의 다른 갈래 하나씩 · 나머지 기본).
|
||||
둘 다 못 셈 = 같음(따로 셈). 보정 없음 — 다르면 목록에 까닭.
|
||||
- 저장 = 같은 것만(요구서_664 1 장 3) — 모든 대조가 같고 부르는 단가산출도 저장되는 문서 ·
|
||||
- 채택된 차이(`ADOPTED`) = 사용자 요구 714-3 「1원 차이는 로직만 맞다면 문제 없음」 — 식 속 단가산출
|
||||
부름 값 = 끝수 뒤(새 계산기 그대로) · 목록에 남기고 저장은 막지 않음.
|
||||
- 저장 = 같은 것만(요구서_664 1 장 3) — 모든 대조가 같고(채택된 차이 빼고) 부르는 단가산출도 저장되는 문서 ·
|
||||
`resources/master_data/수식문서/수식_<옛 로직 파일 꼬리>.json`(로직 파일마다
|
||||
· 옛 로직 JSON 은 안 씀).
|
||||
- 목록 = `docs/raw/요구_1104/_목록_802-2_변환대조.xlsx`(로직 · 옵션 · 옛 · 새 · 차이 · 까닭 + 요약).
|
||||
@@ -44,6 +46,8 @@ RAW_SUB = (
|
||||
"식 속 단가산출 부름 = 끝수 뒤 값"
|
||||
"(옛 엔진 = 끝수 전 날 값 · `master_formula.run` 결과를 식에 씀)"
|
||||
)
|
||||
# 채택된 차이 — (로직, 옵션) · 요구 714-3
|
||||
ADOPTED = {("GC000100", "지역=경북")}
|
||||
|
||||
|
||||
def rows_of(files: dict) -> list[tuple[str, dict]]:
|
||||
@@ -108,7 +112,7 @@ def why(doc: dict, old, new) -> str:
|
||||
if isinstance(new, str) and not isinstance(old, str):
|
||||
return f"새 계산기 못 셈 — {new}"
|
||||
if _uses_raw_sub(doc):
|
||||
return RAW_SUB
|
||||
return RAW_SUB + " · 채택(요구 714-3)"
|
||||
return "까닭 모름"
|
||||
|
||||
|
||||
@@ -151,6 +155,7 @@ def build(log=print) -> dict:
|
||||
)
|
||||
for one in runs:
|
||||
if not one["같음"]:
|
||||
one["채택"] = (one["로직"], one["옵션"]) in ADOPTED
|
||||
one["까닭"] = why(docs[one["로직"]], one["옛"], one["새"])
|
||||
keep = _keep(docs, runs)
|
||||
for one in runs: # 같아도 못 넣는 것 — 부르는 단가산출이 빠짐
|
||||
@@ -165,6 +170,7 @@ def build(log=print) -> dict:
|
||||
"옵션 표본": sum(1 for x in runs if x["옵션"]),
|
||||
"옵션 같음": sum(1 for x in runs if x["옵션"] and x["같음"]),
|
||||
"옵션 다름": sum(1 for x in runs if x["옵션"] and not x["같음"]),
|
||||
"채택된 차이": sum(1 for x in runs if x.get("채택")),
|
||||
"둘 다 못 셈": both_out,
|
||||
"저장": len(keep),
|
||||
"변환 알림": sum(1 for v in notes.values() if v),
|
||||
@@ -192,7 +198,7 @@ def _calls(doc: dict) -> set[str]:
|
||||
def _keep(docs: dict, runs: list[dict]) -> set[str]:
|
||||
"""저장할 문서 — 모든 대조 같음 + 부르는 단가산출도 저장(닫힘).
|
||||
키 구멍 부름은 그 로직 대조로 판정."""
|
||||
keep = set(docs) - {x["로직"] for x in runs if not x["같음"]}
|
||||
keep = set(docs) - {x["로직"] for x in runs if not x["같음"] and not x.get("채택")}
|
||||
while True:
|
||||
drop = {k for k in keep if not _calls(docs[k]) <= keep}
|
||||
if not drop:
|
||||
|
||||
@@ -149,6 +149,11 @@ COLS = {
|
||||
),
|
||||
}
|
||||
COLS["한국은행환율"] = COLS["유가전력"]
|
||||
ALIAS = "별칭" # 요소 줄 덧칸(815-2) — 열 한 벌 밖 · 있으면 빈 글 아님
|
||||
|
||||
|
||||
def _cols_of(row: dict) -> tuple:
|
||||
return tuple(k for k in row if k != ALIAS)
|
||||
|
||||
|
||||
def _detail(name: str) -> str:
|
||||
@@ -310,7 +315,9 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
out.append(f"{where} · 출처 모양 「{row['출처']}」")
|
||||
if data.get("그룹") == "인력":
|
||||
out += _check_labor(where, row)
|
||||
if cols and tuple(row) != cols:
|
||||
if ALIAS in row and not (isinstance(row[ALIAS], str) and row[ALIAS].strip()):
|
||||
out.append(f"{where} · 「{ALIAS}」 이 빈 글 · 글 아님(빈 칸이면 칸을 두지 않음)")
|
||||
if cols and _cols_of(row) != cols:
|
||||
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(cols)}」")
|
||||
if kind == "품셈재료":
|
||||
out += _check_linked(where, row)
|
||||
@@ -328,7 +335,7 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
def _check_labor(where: str, row: dict) -> list[str]:
|
||||
"""인력 줄 — 열 한 벌(차례까지 같음) · 상태 값 · 옛이름 목록."""
|
||||
out = []
|
||||
if tuple(row) != LABOR_COLS:
|
||||
if _cols_of(row) != LABOR_COLS:
|
||||
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(LABOR_COLS)}」")
|
||||
if row.get("상태") not in LABOR_STATUS:
|
||||
out.append(f"{where} · 상태 「{row.get('상태')}」 — {' · '.join(LABOR_STATUS)} 아님")
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""M01 요소 「별칭」 칸 풀이 정본(PLAN 815-1 · 요구 764) — 인력 · 기계 · 재료 · 유가 줄의 `별칭` 글 하나.
|
||||
|
||||
별칭 글 = 콤마(「,」 · 전각 「,」)로 여러 개 · 앞뒤 빈칸 뗌 · 큰따옴표(" · “ ”)로 감싼 조각 = 통째
|
||||
한 별칭(속 콤마 보존 — 이름과 규격을 같이 적을 때 「"결속선(철선) #20, 0.9M/M"」) · 빈 칸 = 없음.
|
||||
`norm` = 표기 정규화(빈칸 · 전각 · 대소문자 · 괄호 · 단위 M/M = mm · 곱셈표 · 지름 Φ · D= · 번호 #) —
|
||||
이름 낱말 순서(「결속선(철선)」 ↔ 「철선(결속선)」)는 맞추지 않음(별칭 몫).
|
||||
뜻: 별칭 = 그 줄을 가리키는 다른 이름 — 이름만 적은 별칭은 줄 규격이 정규화해 같을 때 그 줄 ·
|
||||
이름 + 규격을 적은 별칭은 「이름 규격」 통째가 같을 때 그 줄 · 여럿 = 부르는 쪽이 「후보 여럿」.
|
||||
쓰는 곳 = M02 불러오기 요소 잇기(`M02_Elem_Alias`) · M01 별칭 칸 저장 · 보기. 서버 단독 · 읽기만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
_QUOTES = {'"': '"', "“": "”", "”": "”"}
|
||||
_SEP = (",", ",")
|
||||
_TIMES = re.compile(r"(?<=\d)\s*[x×*]\s*(?=\d)")
|
||||
_DIAM = re.compile(r"[φϕΦøØ∅]|(?<![a-z가-힣])d\s*=\s*(?=\d)")
|
||||
_BRACKETS = re.compile(r"[()\[\]{}〔〕「」『』<>]")
|
||||
|
||||
|
||||
def names(text: Any) -> list[str]:
|
||||
"""별칭 글 → 별칭 목록(차례 그대로 · 같은 것 한 번)."""
|
||||
out: list[str] = []
|
||||
piece, close = "", None
|
||||
for ch in f"{text or ''},":
|
||||
if close:
|
||||
if ch == close or (close == "”" and ch == '"'):
|
||||
close = None
|
||||
else:
|
||||
piece += ch
|
||||
elif ch in _QUOTES and not piece.strip():
|
||||
close, piece = _QUOTES[ch], ""
|
||||
elif ch in _SEP:
|
||||
one = piece.strip()
|
||||
if one and one not in out:
|
||||
out.append(one)
|
||||
piece = ""
|
||||
else:
|
||||
piece += ch
|
||||
if close and piece.strip() and piece.strip() not in out: # 닫는 따옴표 빠짐 = 끝까지 한 조각
|
||||
out.append(piece.strip())
|
||||
return out
|
||||
|
||||
|
||||
def norm(text: Any) -> str:
|
||||
"""표기 정규화 — 같은 글이면 같은 값(맞대기 열쇠)."""
|
||||
t = unicodedata.normalize("NFKC", str(text or "")).lower()
|
||||
t = t.replace("m/m", "mm")
|
||||
t = _TIMES.sub("x", t).replace("×", "x")
|
||||
t = _DIAM.sub("φ", t)
|
||||
t = _BRACKETS.sub("", t)
|
||||
return re.sub(r"\s+", "", t)
|
||||
|
||||
|
||||
def index(rows: Iterable[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
"""요소 줄들(`키` · `별칭`) → {정규화 별칭: [키 …]}(줄 차례)."""
|
||||
out: dict[str, list[str]] = {}
|
||||
for row in rows:
|
||||
key = row.get("키")
|
||||
for one in names(row.get("별칭")) if key else ():
|
||||
got = out.setdefault(norm(one), [])
|
||||
if str(key) not in got:
|
||||
got.append(str(key))
|
||||
return out
|
||||
|
||||
|
||||
def match(
|
||||
idx: dict[str, list[str]], spec_of: Callable[[str], Any], name: Any, spec: Any = ""
|
||||
) -> list[str]:
|
||||
"""가져온 줄(이름 · 규격) → 별칭으로 이어지는 키들 — 「이름 규격」 통째 별칭 › 이름 별칭 + 줄 규격
|
||||
정규화 같음 › (가져온 규격 빔) 이름 별칭 줄 하나 · 없음 = []."""
|
||||
mine = norm(spec)
|
||||
if mine and (whole := idx.get(norm(name) + mine)):
|
||||
return list(whole)
|
||||
named = idx.get(norm(name)) or []
|
||||
same = [k for k in named if norm(spec_of(k)) == mine]
|
||||
if same:
|
||||
return same
|
||||
return list(named) if not mine and len(named) == 1 else []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
assert names('철선, 결속선 ,"결속선(철선) #20, 0.9M/M" , ,철선') == [
|
||||
"철선", "결속선", "결속선(철선) #20, 0.9M/M"
|
||||
] # fmt: skip
|
||||
assert (
|
||||
norm("결속선(철선) #20, 0.9M/M") == norm("결속선 철선#20,0.9㎜") == "결속선철선#20,0.9mm"
|
||||
)
|
||||
assert norm("600×1200") == norm("600 X 1200") == norm("600*1200") == "600x1200"
|
||||
assert norm("PVC D=100") == norm("PVC Φ100") == norm("pvc ø 100") == "pvcφ100"
|
||||
idx = index([{"키": "A", "별칭": "철선"}, {"키": "B", "별칭": '"철선 #8"'}])
|
||||
specs = {"A": "#20, 0.9mm", "B": "#8"}
|
||||
assert match(idx, specs.get, "철선", "#20, 0.9M/M") == ["A"]
|
||||
assert match(idx, specs.get, "철선", "#8") == ["B"]
|
||||
assert match(idx, specs.get, "철선", "#10") == []
|
||||
print("ok")
|
||||
@@ -443,6 +443,7 @@ def _build(c: _Conv, row: dict, given: dict, lone_steps: dict) -> dict:
|
||||
"이름": row.get("이름") or "",
|
||||
"결과단위": row.get("결과단위") or "",
|
||||
"출처": row.get("출처") or "",
|
||||
**({"부품": row["부품"]} if row.get("부품") else {}), # 813-2 부품 표지
|
||||
}
|
||||
money: list[dict] = []
|
||||
if "결과" in row:
|
||||
|
||||
@@ -277,11 +277,6 @@
|
||||
"t": {"이름": "기어 변속시간", "근거": "건설품셈 공통 8-2-1 「t : 기어 변속시간(0.25분)」", "확신": "원문"},
|
||||
"cm": {"이름": "1회 싸이클시간", "근거": "건설품셈 공통 8-2-1 「cm : 1회 싸이클 시간」", "확신": "원문"}
|
||||
},
|
||||
"GC001145": {
|
||||
"운반거리": {"기호": "L", "근거": "건설품셈 공통 8-2-1 「L : 운반거리(m)」", "확신": "원문"},
|
||||
"f": {"이름": "체적환산계수", "근거": "건설품셈 공통 8-2-1 「f : 체적환산계수」", "확신": "원문"},
|
||||
"작업량": {"이름": "시간당 작업량", "기호": "Q", "근거": "건설품셈 공통 8-2-1 「Q : 시간당 작업량(㎥/hr)」 · 식 = 로직 호출(시간당 작업량)", "확신": "원문"}
|
||||
},
|
||||
"GC001146": {
|
||||
"작업거리": {"기호": "ℓ", "근거": "건설품셈 공통 8-2-2 「ℓ : 1회의 작업거리(m)」", "확신": "원문"},
|
||||
"f": {"이름": "체적환산계수", "근거": "건설품셈 공통 8-2-2 「f : 체적환산계수」", "확신": "원문"},
|
||||
@@ -296,11 +291,6 @@
|
||||
"E": {"이름": "작업효율", "근거": "건설품셈 공통 8-2-3 「E : 작업효율」", "확신": "원문"},
|
||||
"cm": {"이름": "1회 싸이클시간", "근거": "건설품셈 공통 8-2-3 「cm : 1회 싸이클 시간(초)」", "확신": "원문"}
|
||||
},
|
||||
"GC001148": {
|
||||
"버킷용량": {"기호": "q", "근거": "건설품셈 공통 8-2-3 「q : 버킷용량(㎥)」", "확신": "원문"},
|
||||
"f": {"이름": "체적환산계수", "근거": "건설품셈 공통 8-2-3 「f : 체적환산계수」", "확신": "원문"},
|
||||
"작업량": {"이름": "시간당 작업량", "기호": "Q", "근거": "건설품셈 공통 8-2-3 「Q : 시간당 작업량(㎥/hr)」 · 식 = 로직 호출(시간당 작업량)", "확신": "원문"}
|
||||
},
|
||||
"GC001149": {
|
||||
"작업거리": {"기호": "L", "근거": "건설품셈 공통 8-2-4 「L : 1열 실작업거리(편도m)」", "확신": "원문"},
|
||||
"전진거리": {"기호": "L", "아래": "1", "근거": "건설품셈 공통 8-2-4 「L_1 : 1열 전진거리(m)」", "확신": "원문"},
|
||||
@@ -320,12 +310,6 @@
|
||||
"cm": {"이름": "1회 싸이클시간", "근거": "건설품셈 공통 8-2-5 「cm : 1회 싸이클 시간(초)」", "확신": "원문"},
|
||||
"E": {"이름": "작업효율", "근거": "건설품셈 공통 8-2-5 「E : 작업효율」", "확신": "원문"}
|
||||
},
|
||||
"GC001151": {
|
||||
"버킷용량": {"기호": "q", "근거": "건설품셈 공통 8-2-5 「q : 버킷용량(㎥)」", "확신": "원문"},
|
||||
"주행거리": {"기호": "ℓ", "근거": "건설품셈 공통 8-2-5 「ℓ : 편도주행거리(표준을 8m로 한다)」", "확신": "원문"},
|
||||
"f": {"이름": "체적환산계수", "근거": "건설품셈 공통 8-2-5 「f : 체적환산계수」", "확신": "원문"},
|
||||
"작업량": {"이름": "운전시간당 작업량", "기호": "Q", "근거": "건설품셈 공통 8-2-5 「Q : 운전시간당 작업량(㎥/hr)」 · 식 = 로직 호출(시간당 작업량)", "확신": "원문"}
|
||||
},
|
||||
"GC001152": {
|
||||
"적재주행거리": {"기호": "L", "아래": "1", "근거": "건설품셈 공통 8-2-6 「L_1 : 적재시의 주행거리(m)」", "확신": "원문"},
|
||||
"공차주행거리": {"기호": "L", "아래": "2", "근거": "건설품셈 공통 8-2-6 「L_2 : 공차시의 주행거리(m)」", "확신": "원문"},
|
||||
@@ -375,16 +359,6 @@
|
||||
"t5": {"기호": "t", "아래": "5", "근거": "열쇠 꼴 ○○+숫자 = 첨자", "확신": "추정"},
|
||||
"t6": {"기호": "t", "아래": "6", "근거": "열쇠 꼴 ○○+숫자 = 첨자", "확신": "추정"}
|
||||
},
|
||||
"GC001156": {
|
||||
"적재용량": {"기호": "T", "근거": "건설품셈 공통 8-2-8 「T : 덤프트럭의 적재용량(ton)」", "확신": "원문"},
|
||||
"체적변화율": {"기호": "L", "근거": "건설품셈 공통 8-2-8 「L : 체적환산계수에서의 체적변화율」", "확신": "원문"},
|
||||
"f": {"이름": "체적환산계수", "근거": "건설품셈 공통 8-2-8 「f : 체적환산계수」", "확신": "원문"},
|
||||
"적재싸이클": {"기호": "cms", "근거": "건설품셈 공통 8-2-8 「cms : 적재기계의 1회 싸이클시간(초)」", "확신": "원문"},
|
||||
"적재버킷": {"기호": "q", "근거": "건설품셈 공통 8-2-8 「q : 흐트러진 상태의 덤프트럭 1회 적재량(㎥)」", "확신": "원문"},
|
||||
"버킷계수": {"기호": "k", "근거": "건설품셈 공통 8-2-8 「k : 리퍼 또는 버킷계수」", "확신": "원문"},
|
||||
"적재효율": {"기호": "Es", "근거": "건설품셈 공통 8-2-8 「Es : 적재기계의 작업효율」", "확신": "원문"},
|
||||
"작업량": {"이름": "1시간당 작업량", "기호": "Q", "근거": "건설품셈 공통 8-2-8 「Q : 1시간당 작업량(㎥/hr)」 · 식 = 로직 호출(시간당 작업량)", "확신": "원문"}
|
||||
},
|
||||
"GC001157": {
|
||||
"다짐횟수": {"기호": "N", "근거": "건설품셈 공통 8-2-9 「N : 소요다짐횟수」", "확신": "원문"},
|
||||
"두께": {"기호": "D", "근거": "건설품셈 공통 8-2-9 「D : 펴는 흙의 두께(m)」", "확신": "원문"},
|
||||
@@ -399,12 +373,6 @@
|
||||
"V": {"이름": "다짐속도", "근거": "건설품셈 공통 8-2-9 「V : 다짐속도(㎞/hr)」", "확신": "원문"},
|
||||
"E": {"이름": "작업효율", "근거": "건설품셈 공통 8-2-9 「E : 작업효율」", "확신": "원문"}
|
||||
},
|
||||
"GC001159": {
|
||||
"다짐횟수": {"기호": "N", "근거": "건설품셈 공통 8-2-9 「N : 소요다짐횟수」", "확신": "원문"},
|
||||
"두께": {"기호": "D", "근거": "건설품셈 공통 8-2-9 「D : 펴는 흙의 두께(m)」", "확신": "원문"},
|
||||
"f": {"이름": "체적환산계수", "근거": "건설품셈 공통 8-2-9 「f : 체적환산계수」", "확신": "원문"},
|
||||
"작업량": {"이름": "시간당 다짐토량", "기호": "Q", "근거": "건설품셈 공통 8-2-9 「Q : 시간당 다짐토량(㎥/hr)」 · 식 = 로직 호출(시간당 작업량)", "확신": "원문"}
|
||||
},
|
||||
"GC001161": {
|
||||
"W": {"이름": "유효혼합폭", "근거": "건설품셈 공통 8-2-11 「W : 유효혼합폭(m)」", "확신": "원문"},
|
||||
"V": {"이름": "작업속도", "근거": "건설품셈 공통 8-2-11 「V : 작업속도(1,000m/hr)」", "확신": "원문"},
|
||||
|
||||
@@ -12574,7 +12574,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-4",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
|
||||
},
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름"
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "아스팔트페이버(피니셔)"
|
||||
},
|
||||
{
|
||||
"키": "EQ000193",
|
||||
@@ -12635,7 +12636,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-4",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
|
||||
},
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름"
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사 · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "아스팔트페이버(피니셔)"
|
||||
},
|
||||
{
|
||||
"키": "EQ000194",
|
||||
@@ -16031,9 +16033,8 @@
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: (m/m) · 주연료종류: 원문 글을 가름",
|
||||
"값원문": {
|
||||
"8-4-5 주연료": "휘발유1.0"
|
||||
}
|
||||
"값원문": { "8-4-5 주연료": "휘발유1.0" },
|
||||
"별칭": "바이브레이트 봉상후렉시블"
|
||||
},
|
||||
{
|
||||
"키": "EQ000249",
|
||||
@@ -25212,7 +25213,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-7",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "주연료종류: 원문 글을 가름"
|
||||
"비고": "주연료종류: 원문 글을 가름",
|
||||
"별칭": "워터제트"
|
||||
},
|
||||
{
|
||||
"키": "EQ000401",
|
||||
@@ -26734,7 +26736,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "주연료종류: 원문 글을 가름"
|
||||
"비고": "주연료종류: 원문 글을 가름",
|
||||
"별칭": "버킷준설기"
|
||||
},
|
||||
{
|
||||
"키": "EQ000426",
|
||||
@@ -31131,7 +31134,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "디젤 엔진"
|
||||
},
|
||||
{
|
||||
"키": "EQ000498",
|
||||
@@ -31192,7 +31196,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "디젤 엔진"
|
||||
},
|
||||
{
|
||||
"키": "EQ000499",
|
||||
@@ -31253,7 +31258,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "디젤 엔진"
|
||||
},
|
||||
{
|
||||
"키": "EQ000500",
|
||||
@@ -31314,7 +31320,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "디젤 엔진"
|
||||
},
|
||||
{
|
||||
"키": "EQ000501",
|
||||
@@ -31375,7 +31382,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "디젤 엔진"
|
||||
},
|
||||
{
|
||||
"키": "EQ000502",
|
||||
@@ -31436,7 +31444,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "디젤 엔진"
|
||||
},
|
||||
{
|
||||
"키": "EQ000503",
|
||||
@@ -31497,7 +31506,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "디젤 엔진"
|
||||
},
|
||||
{
|
||||
"키": "EQ000504",
|
||||
@@ -31558,7 +31568,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "디젤 엔진"
|
||||
},
|
||||
{
|
||||
"키": "EQ000505",
|
||||
@@ -31619,7 +31630,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "디젤 엔진"
|
||||
},
|
||||
{
|
||||
"키": "EQ000506",
|
||||
@@ -31680,7 +31692,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "디젤 엔진"
|
||||
},
|
||||
{
|
||||
"키": "EQ000507",
|
||||
@@ -31741,7 +31754,8 @@
|
||||
"조종원": "건설품셈 공통 8-4-8",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5"
|
||||
},
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름"
|
||||
"비고": "규격 단위: 기종 · 규격(㎾) · 주연료종류: 원문 글을 가름",
|
||||
"별칭": "디젤 엔진"
|
||||
},
|
||||
{
|
||||
"키": "EQ000508",
|
||||
|
||||
@@ -824,7 +824,7 @@
|
||||
"원문번호": "1-2-16",
|
||||
"구분": "건설품셈 유지관리",
|
||||
"상세구분": "01장 공통",
|
||||
"이름": "약제살포(동력분무기)",
|
||||
"이름": "약제살포(기계)",
|
||||
"결과단위": "원/ℓ",
|
||||
"출처": "건설품셈 유지관리 1-2-16",
|
||||
"개정": [2019, 2022],
|
||||
|
||||
@@ -860,7 +860,7 @@
|
||||
"원문번호": "1-6-5",
|
||||
"구분": "건설품셈 토목",
|
||||
"상세구분": "01장 도로포장공사",
|
||||
"이름": "콘크리트 페이버 조립 및 해체",
|
||||
"이름": "기계포설 장비조립 및 해체",
|
||||
"결과단위": "원/회",
|
||||
"출처": "건설품셈 토목 1-6-5",
|
||||
"개정": [2021],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user