Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -14,7 +14,10 @@
|
||||
사전조합으로 감싸 참조(같은 로직 두 번 금지) ·
|
||||
자재 = {코드, 수량 {손}, 세 칸}(같은 자재 = 수량 더함 · 세 칸 = `Material.marks`
|
||||
별도계상 · 자재구분 · 운반비 · 358-2) · 로직 자재와 같은 품목 = 사용자 자재에서 빼고
|
||||
세 칸만 그 로직 `자재표시` 로(빠진 줄에 까닭) · 호표 = 확인된 사전조합 참조.
|
||||
세 칸만 그 로직 `자재표시` 로(빠진 줄에 까닭) · 호표 = 확인된 사전조합 참조 ·
|
||||
「전국」 자재(361-1 · 키 · 단가 없음 · 서버가 줄에서 다시 셈) = 일위대가 자재 칸이 M01 키만
|
||||
받아 `본뜬.전국자재 [{이름, 규격, 단위, 수량, 세 칸}]` 에 적고 빠진 줄에 까닭(금액 없음 ·
|
||||
지역은 프로젝트에서) · 장부엔 안 남김.
|
||||
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음
|
||||
(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌).
|
||||
빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고).
|
||||
@@ -44,6 +47,7 @@ UNCHECKED = "확인 안 함"
|
||||
PERCENT = "% 줄 — 우리 로직 덧줄이 셈"
|
||||
WHOLE = "호표 통째로 대신"
|
||||
TWICE = "로직 자재와 같은 품목 — 세 칸만 로직 자재표시로(수량 = 로직 셈)"
|
||||
NATIONAL = "전국 — 지역은 프로젝트에서(자재 키 · 단가 없음 · 본뜬.전국자재)"
|
||||
|
||||
|
||||
def mark(key: str, no: int, at: int | None = None) -> str:
|
||||
@@ -142,7 +146,9 @@ class Builder:
|
||||
raise _bad(f"없는 호표 「제{no}호표」", 404)
|
||||
|
||||
# ── 재검사 ───────────────────────────────────────────────────────
|
||||
def _target(self, raw: dict[str, Any], where: str) -> dict[str, Any]:
|
||||
def _target(self, raw: dict[str, Any], where: str, line: dict | None = None) -> dict[str, Any]:
|
||||
if raw.get("전국"):
|
||||
return self._national(raw, where, line)
|
||||
if raw.get("호표") is None:
|
||||
return ledger.check_pair(
|
||||
{k: v for k, v in raw.items() if k != "호표"}, where, self.known
|
||||
@@ -157,6 +163,16 @@ class Builder:
|
||||
times, part = ledger.times_of(raw, where)
|
||||
return {"호표": child, "배": times, "몫": part}
|
||||
|
||||
def _national(self, raw: dict[str, Any], where: str, line: dict | None) -> dict[str, Any]:
|
||||
"""「전국」 대상 — 줄에서 서버가 다시 셈(이름 · 규격 · 단위 = 서버 것)."""
|
||||
if set(raw) - {"전국", "배", "몫", "입력값"} or raw.get("입력값"):
|
||||
raise _bad(f"전국 대상은 전국 · 배 · 몫만 「{where}」")
|
||||
one = hmat.national(line) if line is not None else None
|
||||
if one is None:
|
||||
raise _bad(f"전국 자재 없음 — 지역 변형만 있는 자재 줄만 「{where}」")
|
||||
times, part = ledger.times_of(raw, where)
|
||||
return {**{k: one[k] for k in ("전국", "이름", "규격", "단위")}, "배": times, "몫": part}
|
||||
|
||||
def check(self, lines: list[dict[str, Any]], whole: dict[str, Any] | None) -> tuple[dict, list]:
|
||||
"""몸 재검사 → ({차례: 확인 줄}, 통째 대상) — 틀리면 400 · 하위 호표 409."""
|
||||
if whole is not None and lines:
|
||||
@@ -180,7 +196,7 @@ class Builder:
|
||||
if len(targets) > 20:
|
||||
raise _bad(f"대상은 스물 이하 「{where}」")
|
||||
out[at] = {"뺌": raw.get("뺌")} if raw.get("뺌") else {
|
||||
"대상": [self._target(t, where) for t in targets]
|
||||
"대상": [self._target(t, where, have[at]) for t in targets]
|
||||
} # fmt: skip
|
||||
pairs = [self._target(t, f"제{self.no}호표 통째") for t in (whole or {}).get("대상") or []]
|
||||
if any("호표" in p for p in pairs) or len(pairs) > 20:
|
||||
@@ -204,10 +220,21 @@ class Builder:
|
||||
stamp = datetime.now().isoformat(timespec="seconds")
|
||||
hopyo, file = self.hopyo, self.doc.get("파일") or ""
|
||||
lines, mats, missing, used, mat_lines = [], {}, [], set(), {}
|
||||
nation: dict[tuple, dict[str, Any]] = {}
|
||||
|
||||
def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None:
|
||||
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"]))
|
||||
memo = _memo(line, one)
|
||||
if "전국" in one:
|
||||
at = (one["이름"], one["규격"], one["단위"])
|
||||
have = nation.get(at)
|
||||
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
|
||||
head = {"이름": at[0], "규격": at[1], "단위": at[2], "수량": {"손": _num(hand)}}
|
||||
nation[at] = {**head, **(hmat.marks(line, at[0]) if not have else {
|
||||
k: have[k] for k in labor.MARKS if k in have
|
||||
})} # fmt: skip
|
||||
missing.append(_missing(line, NATIONAL))
|
||||
return
|
||||
if "자재" in one:
|
||||
have = mats.get(one["자재"])
|
||||
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
|
||||
@@ -270,7 +297,7 @@ 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]
|
||||
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): # 로직 자재와 같은 품목 = 세 칸만 로직 자재표시로
|
||||
@@ -285,6 +312,8 @@ class Builder:
|
||||
"자재": list(mats.values()),
|
||||
"본뜬": self._origin(mark(self.key, self.no), stamp),
|
||||
}
|
||||
if nation:
|
||||
doc["본뜬"]["전국자재"] = list(nation.values())
|
||||
ref, again = _save(doc)
|
||||
wrote = ledger.write(self.folder, book, self.who, file or None)
|
||||
return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
|
||||
|
||||
@@ -198,7 +198,8 @@ def _names_only(line: dict, ctx: Ctx, why: str, full: bool) -> dict:
|
||||
|
||||
def _material(line: dict, ctx: Ctx, full: bool) -> dict:
|
||||
"""자재 · 별도계상 줄(358-2 · `M02_Hopyo_Material`) — 「별도계상」 글 = 꼬리표 · 맞음 =
|
||||
이름 · 규격 같음 하나 · 또는 바탕 이름 · 규격 수 같은 한 품목(변형 = 첫째 + 꼬리표)."""
|
||||
이름 · 규격 같음 하나 · 또는 바탕 이름 · 규격 수 같은 한 품목(줄 글 지역 · 지역 없는 것 ·
|
||||
「전국」 자리표 · 361-1) · 후보 = 그 품목 변형 모두(`변형` · 고르기 창이 접음)."""
|
||||
me, apart = hmat.clean(line)
|
||||
tags = [hmat.APART] if apart else []
|
||||
found = hm._exact(me, "자재", ctx.got)
|
||||
@@ -207,14 +208,14 @@ def _material(line: dict, ctx: Ctx, full: bool) -> dict:
|
||||
sure, more = hmat.sure(me)
|
||||
found, text, tags = [r["one"] for r in sure], "바탕 이름 · 규격 같음", tags + more
|
||||
if found:
|
||||
one = found[0]
|
||||
conv = hm.factor(line.get("단위"), one["단위"], line.get("규격"))
|
||||
target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv}
|
||||
target.update(몫=None, 배=conv["배"])
|
||||
target = _mat_target(found[0], line)
|
||||
conv = target["환산"]
|
||||
ok = conv["배"] is not None and conv["갈래"] != "추정"
|
||||
many = len(found) > 1
|
||||
cands = [
|
||||
{**hm._public(x), "점수": None, "판정": "맞음" if not at else "후보", "글": text}
|
||||
for at, x in enumerate(found[:6])
|
||||
| ({"변형": True} if many and "자재" in x else {})
|
||||
for at, x in enumerate(found)
|
||||
]
|
||||
out = {"상태": "맞음", "체크": ok, "대상": [target], "후보": cands if full else []}
|
||||
return {**out, "글": text, "꼬리표": tags}
|
||||
@@ -223,6 +224,14 @@ def _material(line: dict, ctx: Ctx, full: bool) -> dict:
|
||||
return {"상태": state, "체크": False, "대상": [], "후보": cands, "꼬리표": tags}
|
||||
|
||||
|
||||
def _mat_target(one: dict, line: dict, times: Any = None) -> dict:
|
||||
"""자재 · 「전국」 자리표 → 대상(배 = 환산 · `times` 주면 그것)."""
|
||||
conv = hm.factor(line.get("단위"), one["단위"], line.get("규격"))
|
||||
target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv}
|
||||
ok = isinstance(times, (int, float)) and not isinstance(times, bool) and times > 0
|
||||
return {**target, "몫": None, "배": times if ok else conv["배"]}
|
||||
|
||||
|
||||
def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]:
|
||||
"""줄 → 맞춤(설계 3-3) — `full` 아니면 상태 · 체크만(호표 목록 수)."""
|
||||
out: dict[str, Any] = {"열쇠": hm.line_key(line), "체크": False, "대상": [], "후보": []}
|
||||
@@ -421,6 +430,8 @@ def guess(
|
||||
targets.append(one)
|
||||
elif t.get("자재") in ctx.mats:
|
||||
targets.append(_pair({"자재": t["자재"], "배": t.get("배")}, line, ctx))
|
||||
elif t.get("전국") and (one := hmat.national(line)):
|
||||
targets.append(_mat_target(one, line, t.get("배")))
|
||||
else:
|
||||
raise store.StoreError(400, "돈 로직 · 우리 자재가 아님")
|
||||
qty = _qty(entry, {"우리품": option.ours_qty(total)})
|
||||
|
||||
@@ -273,7 +273,7 @@ def _seeds() -> dict[str, set[str]]:
|
||||
|
||||
|
||||
def _public(one: dict[str, Any]) -> dict[str, Any]:
|
||||
keep = ("로직", "자재", "이름", "규격", "단위")
|
||||
keep = ("로직", "자재", "전국", "이름", "규격", "단위")
|
||||
return {k: one[k] for k in keep if k in one}
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
자재 규격 수에 있는 몫(단위 배 1 · 10 · 1000 · 2 % 허용 · 면목 0.03 m = 30 mm) ·
|
||||
줄 괄호 글 낱말(「몸통」)은 자재 이름에 들어야 함.
|
||||
맞음 = ① 이름 · 규격 같음 하나(`Match._exact`) ② 바탕 이름 · 단위 · 규격 맞음 1 이 한 품목뿐
|
||||
(지역 · 관급/사급만 다른 변형 = 한 품목 · 같은 단위 먼저 · 줄 글의 관급/사급 · 지역 먼저 ·
|
||||
없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급 · 지역 없는 것 · M01 차례 → 꼬리표 「못 가림」).
|
||||
(지역 · 관급/사급만 다른 변형 = 한 품목 · 같은 단위 먼저 · 줄 글의 관급/사급 먼저 · 없으면
|
||||
별도계상 줄 = 관급 · 재료비 줄 = 사급).
|
||||
지역(361-1) = 줄 글에 지역 낱말 → 그 지역 변형(꼬리표 「지역 — 줄 글」) · 없으면 지역 없는
|
||||
변형((전국) 품목) · 그것도 없으면(레미콘) 「전국」 = 키 · 단가 없는 자리표(`national` ·
|
||||
바탕 이름(전국)-관급/사급 · 규격 · 단위 · 꼬리표 「전국 — 지역은 프로젝트에서」) · 자동 고름 없음 ·
|
||||
지역 변형은 고르기 창에서 사용자가.
|
||||
그 밖 = 후보 다섯(변형은 하나로 접고 글에 수). 곳 낱말 = M01 레미콘 이름에서(자료).
|
||||
가져오기 세 칸(`marks`) = 별도계상(표시 → 별도계상 · 아니면 직접계산) · 자재구분(고른
|
||||
자재 이름 · 줄 글) · 운반비(「운반비 미포함 · 별도」 → 미포함). 로직 판정과 무관.
|
||||
@@ -33,6 +37,9 @@ REGIONS = (
|
||||
*"여주 이천 가평 연천".split(),
|
||||
)
|
||||
SKIP_WORDS = frozenset({"조합공통품목"})
|
||||
NATIONAL = "전국"
|
||||
NATIONAL_TAG = "전국 — 지역은 프로젝트에서"
|
||||
REGION_TAG = "지역 — 줄 글"
|
||||
_APART = re.compile(r"\(?\s*별도\s*계상\s*\)?")
|
||||
_NUM = re.compile(r"\d+(?:\.\d+)?")
|
||||
_RANGE = re.compile(r"(\d+(?:\.\d+)?)\s*(<=|≤|<)\s*[A-Za-z]\s*(<=|≤|<)\s*(\d+(?:\.\d+)?)")
|
||||
@@ -60,8 +67,8 @@ def _paren_words(name: str) -> set[str]:
|
||||
return {w for g in re.findall(r"\(([^()]*)\)", name) for w in re.findall(r"[가-힣]{2,}", g)}
|
||||
|
||||
|
||||
def base(name: Any) -> str:
|
||||
"""바탕 이름 — 괄호 글 · 끝 지역 · 관급/사급 꼬리 뗌(정규화)."""
|
||||
def _bare(name: Any) -> str:
|
||||
"""괄호 글 · 끝 지역 · 관급/사급 꼬리 뗀 글(정규화 전 · 「레미콘(서울)-사급」 → 레미콘)."""
|
||||
text = _nfkc(name)
|
||||
while "(" in text and (cut := re.sub(r"\([^()]*\)", "", text)) != text:
|
||||
text = cut
|
||||
@@ -71,7 +78,12 @@ def base(name: Any) -> str:
|
||||
text = text[: m.start()]
|
||||
for w in SUPPLY:
|
||||
text = text[: -len(w)] if text.endswith(w) and len(text) > len(w) else text
|
||||
return mm.plain(text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def base(name: Any) -> str:
|
||||
"""바탕 이름 — `_bare` 정규화."""
|
||||
return mm.plain(_bare(name))
|
||||
|
||||
|
||||
def clean(line: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
@@ -166,16 +178,21 @@ def _wants(line: dict[str, Any]) -> tuple[str | None, list[str]]:
|
||||
return supply, [w for w in words if _region(w) and w not in NO_REGION]
|
||||
|
||||
|
||||
def _hit(r: dict[str, Any], regions: list[str]) -> bool:
|
||||
return any(w[:2] == x[:2] for w in regions for x in r["region"])
|
||||
|
||||
|
||||
def _prefer(rows: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""변형 차례 — 줄 글의 관급/사급 · 지역 먼저 · 지역 없는 것 · 엑셀(M01) 차례."""
|
||||
supply, regions = _wants(line)
|
||||
|
||||
def rank(r: dict[str, Any]) -> tuple:
|
||||
hit = any(w[:2] == x[:2] for w in regions for x in r["region"])
|
||||
hit = _hit(r, regions)
|
||||
return (
|
||||
supply is not None and r["supply"] not in (None, supply),
|
||||
bool(regions) and not hit,
|
||||
bool(r["region"]),
|
||||
bool(r["region"]) or not any(w in r["one"]["이름"] for w in NO_REGION), # (전국) 먼저
|
||||
r["at"],
|
||||
)
|
||||
|
||||
@@ -201,8 +218,30 @@ def sure(line: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
if not full or len({r["key"] for r in full}) > 1:
|
||||
return [], []
|
||||
full = _prefer(full, line)
|
||||
tags = [f"지역 · 관급/사급 못 가림 — {len(full)}개 중 첫째"] if len(full) > 1 else []
|
||||
return full, tags
|
||||
supply, regions = _wants(line)
|
||||
if any(_hit(r, regions) for r in full):
|
||||
return full, [REGION_TAG]
|
||||
if len(full) == 1 or any(not r["region"] for r in full):
|
||||
return full, []
|
||||
return [_national(full[0], supply), *full], [NATIONAL_TAG]
|
||||
|
||||
|
||||
def _national(row: dict[str, Any], supply: str | None) -> dict[str, Any]:
|
||||
"""「전국」 자리표(361-1) — 지역 변형만 있는 품목 · 키 · 단가 없음 · 이름(전국)-관급/사급."""
|
||||
one = row["one"]
|
||||
name = f"{_bare(one['이름'])}({NATIONAL})" + (f"-{supply}" if supply else "")
|
||||
spec = _nfkc(one["규격"])
|
||||
for w in SKIP_WORDS: # 「조합공통품목, 25-21-120」(관급) → 25-21-120
|
||||
spec = re.sub(rf"{w}[\s,]*", "", spec)
|
||||
return {"one": {"전국": True, "이름": name, "규격": spec.strip(" ,"), "단위": one["단위"]}}
|
||||
|
||||
|
||||
def national(line: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""자재 · 별도계상 줄(다듬기 전)의 「전국」 자리표 — 없으면 None(만들기 · 미리 셈 재검사)."""
|
||||
if line.get("갈래") not in hm.MATERIAL_KINDS:
|
||||
return None
|
||||
rows, _ = sure(clean(line)[0])
|
||||
return next((r["one"] for r in rows if r["one"].get("전국")), None)
|
||||
|
||||
|
||||
def candidates(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -334,6 +334,7 @@ class BuildTarget(_Strict):
|
||||
로직: str | None = None
|
||||
자재: str | None = None
|
||||
호표: int | None = None
|
||||
전국: bool | None = None # 「전국」 자재(361-1 · 키 없음 · 서버가 줄에서 다시 셈)
|
||||
입력값: dict[str, Any] = Field(default_factory=dict)
|
||||
배: float = 1
|
||||
몫: float | None = None
|
||||
|
||||
@@ -89,6 +89,10 @@ export interface HopyoTarget {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
호표?: number;
|
||||
/** 「전국」 자리표(361-1) — 지역 변형만 있는 자재 · 키 · 단가 없음 */
|
||||
전국?: boolean;
|
||||
/** 같은 품목 지역 · 관급/사급 변형(361-1 · 고르기 창이 접음) */
|
||||
변형?: boolean;
|
||||
이름?: string;
|
||||
규격?: string;
|
||||
단위?: string | null;
|
||||
@@ -190,6 +194,8 @@ export interface BuildTarget {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
호표?: number;
|
||||
/** 「전국」 자재(361-1) — 이름 · 규격 · 단위는 서버가 줄에서 다시 셈 */
|
||||
전국?: boolean;
|
||||
입력값?: Record<string, unknown>;
|
||||
배: number;
|
||||
몫?: number | null;
|
||||
|
||||
@@ -117,7 +117,11 @@ export function checkable(line: HopyoLine, d: CheckDraft): boolean {
|
||||
/** 대상 → 만들기 몸 대상(이름 · 품 같은 보이기 칸은 안 보냄) */
|
||||
export function toTarget(p: HopyoPart): BuildTarget {
|
||||
if (p.호표 !== undefined) return { 호표: p.호표, 배: p.배 ?? 1 };
|
||||
const head = p.로직 ? { 로직: p.로직, 입력값: p.입력값 ?? {} } : { 자재: p.자재 };
|
||||
const head = p.로직
|
||||
? { 로직: p.로직, 입력값: p.입력값 ?? {} }
|
||||
: p.전국
|
||||
? { 전국: true }
|
||||
: { 자재: p.자재 };
|
||||
return { ...head, 배: p.배 ?? 1, ...(p.몫 != null ? { 몫: p.몫 } : {}) };
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,8 @@ function partLines(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLE
|
||||
times.addEventListener("change", () =>
|
||||
ctx.times(line, at, times.value === "" ? null : Number(times.value)),
|
||||
);
|
||||
const key = p.로직 ?? p.자재 ?? (p.호표 !== undefined ? `제${p.호표}호표` : "");
|
||||
const key =
|
||||
p.로직 ?? p.자재 ?? (p.호표 !== undefined ? `제${p.호표}호표` : p.전국 ? "전국" : "");
|
||||
return el("div", {
|
||||
className: "m02-hopyo__part",
|
||||
attrs: { title: p.어디 ?? "" },
|
||||
@@ -247,7 +248,7 @@ function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement {
|
||||
el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }),
|
||||
el("td", {
|
||||
className: "m02-labrow__code",
|
||||
text: parts.map((x) => x.자재 ?? "").join(" · ") || "—",
|
||||
text: parts.map((x) => x.자재 ?? (x.전국 ? "전국" : "")).join(" · ") || "—",
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__name m02-hopyo__parts",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록)
|
||||
* · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
|
||||
* · [다른 로직 검색] = `openLogicPicker` 에 품명 첫 뜻 낱말 · [다른 자재 검색] = 자재 줄 · 재료비 있는 줄(358-2)
|
||||
* · 자재 줄(361-1) = 1위 / 전국(자리표 · 지역 고른 뒤 되돌림) / 지역 변형(접힘 · 하나 고르면 그 지역) / 그 밖
|
||||
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -25,6 +26,10 @@ import { pickMaterial } from "./M02_Material_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
const MATERIAL = new Set(["자재", "별도계상"]);
|
||||
const FOLD_TEXT: Record<string, string> = {
|
||||
"지역 변형": "하나 고르면 그 지역 · 「전국」으로 되돌림은 위",
|
||||
숫자: "이름 · 출처 안 닮음 · 숫자 지문만",
|
||||
};
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
const num = (v: number): string => String(Math.round(v * 10000) / 10000);
|
||||
|
||||
@@ -131,6 +136,8 @@ export function candGroups(
|
||||
if (material)
|
||||
return [
|
||||
{ 제목: "1위", 접힘: false, 후보: top },
|
||||
{ 제목: "전국", 접힘: false, 후보: take(all.filter((c) => c.전국)) },
|
||||
{ 제목: "지역 변형", 접힘: true, 후보: take(all.filter((c) => c.변형)) },
|
||||
{ 제목: "그 밖 후보", 접힘: false, 후보: take(all.slice(1)) },
|
||||
].filter((g) => g.후보.length);
|
||||
const named = m.이름후보 ?? [];
|
||||
@@ -156,7 +163,7 @@ export function sameNames(groups: CandGroup[]): Set<string> {
|
||||
|
||||
/** 후보 단추 글 — 「이름 [단위] · 키」 · 같은 이름이면 책 두 글자(산림 · 건설) */
|
||||
export const candText = (c: HopyoTarget, twins: Set<string>): string =>
|
||||
`${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`;
|
||||
`${targetText(c)} · ${c.로직 ?? c.자재 ?? "키 없음"}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`;
|
||||
|
||||
/** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */
|
||||
export const canPickMaterial = (line: Pick<HopyoLine, "갈래" | "단가">): boolean =>
|
||||
@@ -165,7 +172,7 @@ export const canPickMaterial = (line: Pick<HopyoLine, "갈래" | "단가">): boo
|
||||
/** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */
|
||||
export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] =>
|
||||
picked.map(({ c, 몫 }) => ({
|
||||
...(c.로직 ? { 로직: c.로직 } : { 자재: c.자재 }),
|
||||
...(c.로직 ? { 로직: c.로직 } : c.전국 ? { 전국: true } : { 자재: c.자재 }),
|
||||
입력값: {},
|
||||
배: 몫 ?? 1,
|
||||
몫,
|
||||
@@ -220,7 +227,7 @@ export function openHopyoChoose(opt: ChooseOptions): void {
|
||||
const tag = basisTag(c.근거);
|
||||
return el("div", {
|
||||
className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`,
|
||||
attrs: { "data-cand": c.로직 ?? c.자재 ?? "" },
|
||||
attrs: { "data-cand": c.로직 ?? c.자재 ?? (c.전국 ? "전국" : "") },
|
||||
children: [
|
||||
box,
|
||||
createButton({
|
||||
@@ -260,7 +267,10 @@ export function openHopyoChoose(opt: ChooseOptions): void {
|
||||
const fold = el("details", {
|
||||
className: "m02-hopyo__group",
|
||||
attrs: { "data-group": g.제목 },
|
||||
children: [el("summary", { text: `${title} — 이름 · 출처 안 닮음 · 숫자 지문만` }), list],
|
||||
children: [
|
||||
el("summary", { text: `${title} — ${FOLD_TEXT[g.제목] ?? FOLD_TEXT.숫자}` }),
|
||||
list,
|
||||
],
|
||||
});
|
||||
return fold;
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 정본 = 서버 `M02_Hopyo_Build.Builder.build` 의 `put`([사전조합 생성] 때 다시 셈) — 같은 규칙:
|
||||
* 우리 수량 = 엑셀 수량 × 배 · 로직 = {로직, 입력값, 수량 {손}} · 자재 = {코드, 수량 {손}, 별도계상 =
|
||||
* 갈래 별도계상이면 「별도계상」 아니면 「직접계산」} · 같은 자재 = 수량 더함.
|
||||
* 「전국」 자재(361-1 · 키 · 단가 없음) = 자재 표 · 문서엔 안 넣음(Build = 본뜬.전국자재 · 금액 없음).
|
||||
* 미리 보기만 다른 것: 호표 대상(하위 사전조합 — 만든 뒤 셈) · 배 없는 대상은 뺌 ·
|
||||
* 같은 로직 둘째는 뺌(Build = 한 줄 사전조합으로 감쌈 · 한 문서에 같은 로직 둘 = 서버 400).
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
@@ -18,9 +19,9 @@ export type CheckTable = "logic" | "mat";
|
||||
/** 엑셀 갈래 중 자재 표로 가는 것(대상 없을 때) */
|
||||
const MAT_KINDS = ["자재", "별도계상"];
|
||||
|
||||
/** 줄이 들어갈 표 — 대상이 모두 자재면 자재 표 · 대상 없으면 갈래(자재 · 별도계상) · 그 밖 로직 표 */
|
||||
/** 줄이 들어갈 표 — 대상이 모두 자재(전국 포함)면 자재 표 · 대상 없으면 갈래(자재 · 별도계상) · 그 밖 로직 표 */
|
||||
export function tableOf(line: Pick<HopyoLine, "갈래">, parts: HopyoPart[]): CheckTable {
|
||||
if (parts.length) return parts.every((p) => !!p.자재) ? "mat" : "logic";
|
||||
if (parts.length) return parts.every((p) => !!p.자재 || !!p.전국) ? "mat" : "logic";
|
||||
return MAT_KINDS.includes(line.갈래) ? "mat" : "logic";
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ let out0 = null;
|
||||
toTarget: [
|
||||
check.toTarget({ 호표: 46, 이름: "고화매트", 입력값: {}, 배: 2.3 }),
|
||||
check.toTarget({ 자재: "MT1", 이름: "레미콘", 입력값: {}, 배: 1, 몫: null }),
|
||||
check.toTarget({ 전국: true, 이름: "레미콘(전국)-관급", 입력값: {}, 배: 1, 몫: null }),
|
||||
],
|
||||
newest: [a(), b(), c()],
|
||||
dropped: (check.dropDraft(key), store.has(key)),
|
||||
@@ -200,6 +201,25 @@ let out0 = null;
|
||||
material: choose
|
||||
.candGroups({ 후보: [{ 자재: "M1" }, { 자재: "M2" }] }, true)
|
||||
.map((x) => x.제목),
|
||||
// 361-1 지역만 다른 자재 — 1위 전국 · 지역 변형 접힘 · 고르면 그 지역 · 전국 되돌림
|
||||
national: (() => {
|
||||
const all = [
|
||||
{ 전국: true, 이름: "레미콘(전국)-관급", 규격: "25-21-120", 단위: "㎥" },
|
||||
{ 자재: "M1", 이름: "레미콘(서울)-사급", 변형: true },
|
||||
{ 자재: "M2", 이름: "레미콘(관급)-강원도(강릉시)", 변형: true },
|
||||
];
|
||||
const g = choose.candGroups({ 후보: all }, true);
|
||||
const id = (c) => c.자재 ?? (c.전국 ? "전국" : "");
|
||||
return {
|
||||
groups: g.map((x) => [x.제목, x.접힘, x.후보.map(id)]),
|
||||
text: choose.candText(all[0], new Set()),
|
||||
region: choose.pickedTargets([{ c: all[2], 몫: null }]),
|
||||
back: choose.pickedTargets([{ c: all[0], 몫: null }]),
|
||||
hit: choose
|
||||
.candGroups({ 후보: [all[2], all[0], all[1]] }, true)
|
||||
.map((x) => [x.제목, x.후보.map(id)]),
|
||||
};
|
||||
})(),
|
||||
pick: [
|
||||
choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: 10 } }),
|
||||
choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: null } }),
|
||||
@@ -241,6 +261,7 @@ let out0 = null;
|
||||
none: [
|
||||
draft.draftDoc({ 수량: 1, 갈래: "단가산출" }, []),
|
||||
draft.draftDoc({ 수량: 1, 갈래: "호표" }, [{ 호표: 3, 입력값: {}, 배: 1 }]),
|
||||
draft.draftDoc({ 수량: 1, 갈래: "별도계상" }, [{ 전국: true, 입력값: {}, 배: 1 }]),
|
||||
],
|
||||
table: [
|
||||
draft.tableOf({ 갈래: "단가산출" }, [M]),
|
||||
@@ -248,6 +269,7 @@ let out0 = null;
|
||||
draft.tableOf({ 갈래: "자재" }, [L, M]),
|
||||
draft.tableOf({ 갈래: "별도계상" }, []),
|
||||
draft.tableOf({ 갈래: "%" }, []),
|
||||
draft.tableOf({ 갈래: "별도계상" }, [{ 전국: true, 입력값: {}, 배: 1 }]),
|
||||
],
|
||||
};
|
||||
})(),
|
||||
|
||||
@@ -146,6 +146,34 @@ def test_자재_세_칸_로직_자재_두_번_막음(client):
|
||||
assert any(m["까닭"] == hb.TWICE and m["차례"] == 0 for m in got["빠진줄"])
|
||||
|
||||
|
||||
def test_전국_자재_키_없이_본뜬에(client):
|
||||
"""361-1 — 「전국」 자재 = 자재 칸(M01 키만) 아니고 `본뜬.전국자재` · 이름 · 규격 · 단위 =
|
||||
서버가 줄에서 다시 셈(몸은 이름 칸 못 받음) · 빠진 줄 까닭 · 장부엔 안 남김 · 자리표 없는 줄 = 400."""
|
||||
assert _post(client).status_code == 200
|
||||
line = {"차례": 0, "대상": [{"전국": True, "배": 1}]} # 레미콘 별도계상 줄
|
||||
got = _build(client, 1, [line])
|
||||
mine = layers.personal_dir(7, 42)
|
||||
doc = _doc(mine, got["참조"]["키"])
|
||||
assert doc["자재"] == [] and not any(m.get("코드") for m in doc["자재"])
|
||||
qty = hs.read(mine, "HP-000001")["호표"][0]["줄"][0]["수량"]
|
||||
assert doc["본뜬"]["전국자재"] == [
|
||||
{
|
||||
"이름": "레미콘(전국)-관급",
|
||||
"규격": "25-21-120",
|
||||
"단위": "㎥",
|
||||
"수량": {"손": qty},
|
||||
"별도계상": "별도계상",
|
||||
"자재구분": "관급",
|
||||
}
|
||||
]
|
||||
assert [m["까닭"] for m in got["빠진줄"] if m["차례"] == 0] == [hb.NATIONAL]
|
||||
assert got["장부"]["씀"] == 0
|
||||
_build(client, 1, [{"차례": 1, "대상": [{"전국": True, "배": 1}]}], want=400) # 로직 줄
|
||||
both = [{"차례": 0, "대상": [{"전국": True, "자재": "MT004258", "배": 1}]}]
|
||||
_build(client, 1, both, want=400)
|
||||
_build(client, 1, [{"차례": 0, "대상": [{"전국": True, "입력값": {"x": 1}}]}], want=400)
|
||||
|
||||
|
||||
def test_몸_없음_재검사_400(client):
|
||||
assert _post(client).status_code == 200
|
||||
one = {"로직": "GF000101", "배": 1}
|
||||
|
||||
@@ -92,7 +92,9 @@ def test_끝까지_확인한_줄만_별칭(client):
|
||||
assert {p["로직"] for p in two} == {"GC000206", "GC000207"} # 다른 점 → 사용자가 체크
|
||||
body = body + [{"차례": rebar["차례"], "대상": two}]
|
||||
made = _build(client, "HP-000001", no, body)
|
||||
assert made["다시씀"] is False and made["장부"]["씀"] == len(body)
|
||||
national = [x for x in body if any(t.get("전국") for t in x.get("대상", []))] # 361-1
|
||||
assert national and all(x["대상"] == [{"전국": True, "배": 1}] for x in national)
|
||||
assert made["다시씀"] is False and made["장부"]["씀"] == len(body) - len(national)
|
||||
assert all(m["까닭"] for m in made["빠진줄"]) # 확인 안 함 · % 줄
|
||||
doc = _doc(made["참조"])
|
||||
logics = [x for x in doc["담은로직"] if x.get("로직")]
|
||||
|
||||
@@ -115,11 +115,25 @@ def test_새_상태_체크_제안(client):
|
||||
assert remicon["품"]["같음"]["콘크리트공"] and remicon["품"]["엑셀"]["인력"]["콘크리트공"]
|
||||
plywood = first["합판거푸집"]
|
||||
assert plywood["상태"] == "맞음" and set(plywood["갈리는칸"]) == {"측압", "폼타이규격"}
|
||||
mat = first["레미콘(25-21-120)"] # 358-2 — 「별도계상」 글 = 표시 · 지역 변형 = 첫째 + 꼬리표
|
||||
mat = first["레미콘(25-21-120)"] # 358-2 · 361-1 — 「별도계상」 표시 · 지역 변형만 = 전국
|
||||
assert mat["상태"] == "맞음" and mat["체크"] is True and "별도계상" in mat["꼬리표"]
|
||||
assert mat["대상"][0]["규격"].endswith("25-21-120") and "레미콘" in mat["대상"][0]["이름"]
|
||||
assert any(t.startswith("지역 · 관급/사급 못 가림") for t in mat["꼬리표"])
|
||||
assert all(c["규격"].endswith("25-21-120") for c in mat["후보"]) # 변형 = 고를 수 있게
|
||||
assert mat["대상"][0] | {"환산": None} == {
|
||||
"전국": True,
|
||||
"이름": "레미콘(전국)-관급",
|
||||
"규격": "25-21-120",
|
||||
"단위": "㎥",
|
||||
"입력값": {},
|
||||
"못읽음": [],
|
||||
"출처": {},
|
||||
"환산": None,
|
||||
"몫": None,
|
||||
"배": 1,
|
||||
} # 특정 지역 키 없음
|
||||
assert "전국 — 지역은 프로젝트에서" in mat["꼬리표"]
|
||||
assert not any("못 가림" in t for t in mat["꼬리표"])
|
||||
assert mat["후보"][0]["전국"] and len(mat["후보"]) == 129 # 전국 + 지역 변형 128(접어 고름)
|
||||
assert all(c["변형"] and c["자재"] for c in mat["후보"][1:])
|
||||
assert all(c["규격"].endswith("25-21-120") for c in mat["후보"])
|
||||
dig = _hopyo(client, 3)["구조물터파기(인력100%)"]
|
||||
assert dig["상태"] == "다른 점" and dig["체크"] is False
|
||||
assert dig["대상"][0]["로직"] == "GC000083" and len(dig["후보"]) == 5
|
||||
@@ -330,6 +344,61 @@ def test_북부청_자재_별도계상_맞음_늚(client):
|
||||
assert mortar["후보"][0]["자재"] == "MT004251"
|
||||
|
||||
|
||||
# ── 지역만 다른 자재 = 전국(361-1) ─────────────────────────────────────
|
||||
REMICON = {"이름": "레미콘(25-21-120)", "규격": "별도계상", "단위": "㎥", "갈래": "별도계상"}
|
||||
H_BEAM = {"이름": "H 형강", "규격": "SS275, 100*100*6*8mm", "단위": "Ton", "갈래": "자재"}
|
||||
|
||||
|
||||
def test_전국_지역_줄글_전국품목():
|
||||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||||
|
||||
rows, tags = hmat.sure(hmat.clean(REMICON)[0]) # 지역 낱말 없음 · (전국) 품목 없음
|
||||
assert rows[0]["one"] == {
|
||||
"전국": True,
|
||||
"이름": "레미콘(전국)-관급",
|
||||
"규격": "25-21-120",
|
||||
"단위": "㎥",
|
||||
}
|
||||
assert tags == [hmat.NATIONAL_TAG] and all("자재" in r["one"] for r in rows[1:])
|
||||
paid = {**REMICON, "규격": "", "갈래": "자재", "단가": {"재료비": 90000}}
|
||||
assert hmat.national(paid)["이름"] == "레미콘(전국)-사급" # 재료비 줄 = 사급 꼬리
|
||||
named = {**REMICON, "비고": "강원도 강릉시"} # 줄 글에 지역 = 그 지역(원문)
|
||||
rows, tags = hmat.sure(hmat.clean(named)[0])
|
||||
assert rows[0]["one"]["이름"] == "레미콘(관급)-강원도(강릉시)" and tags == [hmat.REGION_TAG]
|
||||
assert hmat.national(named) is None
|
||||
rows, tags = hmat.sure(hmat.clean(H_BEAM)[0]) # (전국) 품목 있음 = 그것
|
||||
assert rows[0]["one"]["자재"] == "MT001694" and tags == []
|
||||
assert hmat.national(H_BEAM) is None
|
||||
seoul = hmat.sure(hmat.clean({**H_BEAM, "비고": "서울"})[0])[0][0]["one"]
|
||||
assert seoul["이름"] == "H 형강(서울)"
|
||||
|
||||
|
||||
def test_전국_고르기_지역_되돌림(client):
|
||||
_ready(client)
|
||||
no, at = (
|
||||
1,
|
||||
next(
|
||||
x["차례"]
|
||||
for x in client.get(f"{URL.format('personal')}/HP-000001/1").json()["호표"]["줄"]
|
||||
if x["이름"] == "레미콘(25-21-120)"
|
||||
),
|
||||
)
|
||||
path = f"{URL.format('personal')}/HP-000001/{no}/guess"
|
||||
seoul = client.post(path, json={"차례": at, "대상": [{"자재": "MT004258", "배": 1}]})
|
||||
assert seoul.status_code == 200 and seoul.json()["대상"][0]["자재"] == "MT004258"
|
||||
back = client.post(path, json={"차례": at, "대상": [{"전국": True, "배": 1}]})
|
||||
assert back.status_code == 200, back.text
|
||||
got = back.json()["대상"][0]
|
||||
assert got["전국"] and "자재" not in got and got["이름"] == "레미콘(전국)-관급"
|
||||
other = next( # 전국 자리표 없는 줄(로직 줄) = 400
|
||||
x["차례"]
|
||||
for x in client.get(f"{URL.format('personal')}/HP-000001/1").json()["호표"]["줄"]
|
||||
if x["이름"] == "레미콘타설(장비사용)"
|
||||
)
|
||||
bad = client.post(path, json={"차례": other, "대상": [{"전국": True, "배": 1}]})
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
# ── 단계 대조 보임(358-3) — 목록 수 = 확인 창 수 · 연결 두 칸 · 목록 열 때 뒤 셈 ─────────────
|
||||
def test_목록_수_확인_창_수_한_규칙(client):
|
||||
_ready(client)
|
||||
|
||||
@@ -129,7 +129,11 @@ def test_확인_창_체크한_줄만_몸():
|
||||
assert got["sure"] == [1, 2] # [맞음 · 별칭 일괄 체크]
|
||||
assert got["compare"] == "콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠"
|
||||
assert got["compareMany"] == "굴착기 0.1594 → 0.0889 ≠" # 같은 장비 여러 줄 = 합쳐 한 줄
|
||||
assert got["toTarget"] == [{"호표": 46, "배": 2.3}, {"자재": "MT1", "배": 1}]
|
||||
assert got["toTarget"] == [
|
||||
{"호표": 46, "배": 2.3},
|
||||
{"자재": "MT1", "배": 1},
|
||||
{"전국": True, "배": 1}, # 361-1 — 키 없음 · 이름은 서버가
|
||||
]
|
||||
assert got["newest"] == [False, True, True] # 같은 줄 늦은 응답 버림 · 다른 줄은 따로
|
||||
assert got["dropped"] is False # 만들기 성공 = 캐시 지움
|
||||
|
||||
@@ -145,6 +149,16 @@ def test_고르기_창_도우미():
|
||||
]
|
||||
assert got["groups"]["text"][:2] == ["되메우기 · A · 산림", "되메우기 · C · 건설"]
|
||||
assert got["groups"]["material"] == ["1위", "그 밖 후보"]
|
||||
nat = got["groups"]["national"] # 361-1
|
||||
assert nat["groups"] == [["1위", False, ["전국"]], ["지역 변형", True, ["M1", "M2"]]]
|
||||
assert nat["text"] == "레미콘(전국)-관급 25-21-120 [㎥] · 키 없음"
|
||||
assert nat["region"] == [{"자재": "M2", "입력값": {}, "배": 1, "몫": None}]
|
||||
assert nat["back"] == [{"전국": True, "입력값": {}, "배": 1, "몫": None}]
|
||||
assert nat["hit"] == [ # 줄 글 지역 = 1위 · 전국 = 되돌릴 자리 · 남은 변형 접힘
|
||||
["1위", ["M2"]],
|
||||
["전국", ["전국"]],
|
||||
["지역 변형", ["M1"]],
|
||||
]
|
||||
assert got["groups"]["pick"] == [True, False, True] # 재료비 있는 단가산출 줄도 자재 검색
|
||||
assert got["picked"] == [
|
||||
{"로직": "GC000206", "입력값": {}, "배": 1, "몫": None},
|
||||
@@ -463,8 +477,8 @@ def test_확인_창_표_일위대가_상세_꼴_359():
|
||||
"별도계상": "별도계상"},
|
||||
] # fmt: skip
|
||||
assert got["direct"]["별도계상"] == "직접계산" and got["direct"]["수량"] == {"손": 0}
|
||||
assert got["none"] == [None, None]
|
||||
assert got["table"] == ["mat", "logic", "logic", "mat", "logic"]
|
||||
assert got["none"] == [None, None, None] # 전국 자재(361-1) = 키 없어 미리 셈 없음
|
||||
assert got["table"] == ["mat", "logic", "logic", "mat", "logic", "mat"]
|
||||
rows = _text("M02_MasterTemplete_Hopyo_CheckRows.ts")
|
||||
assert '["", "추가"],' in rows and '["우리에 없음", "로직없음"],' in rows
|
||||
assert '["안 씀", "사용안함"],' in rows and "뺌 — " not in rows
|
||||
|
||||
Reference in New Issue
Block a user