feat(778-4): 율 줄을 단가산출(SK)에 담음 — 고정 비율 더한줄 · 기준 묶음 · 줄 목록 · 율 위 율
- SK 더한줄 율 줄 허용(일위대가 비율 줄과 같은 꼴 `고정 비율 {기준, 비목}` · 수량 = 율)
- 기준 = 비목(노무비 · 재료비 · 경비) · 묶음(인력 · 재료 · 장비 = 그 줄 종류만 · 기계 노무 섞인 블록도 인력 줄만) ·
줄 목록(cid · add:#k · 앞 율 줄 = 율 위 율) · 검사 = 자기 · 뒤 율 줄 막음
- 밑 공용 함수 `M02_Labor_Fixed.rate_base` ↔ 짝 `Labor_Calc.ts` `rateBase`(일위대가 비율 줄 · Expand 같이 씀)
- SK 셈 `_rated` = 엔진 한 번 돈 줄로 밑 → 율 줄 값묶음 → 엔진 다시(율 줄 없는 문서 = 한 번 · 값 그대로)
- B09 pct 밑수 `bundle:` · `rows:`(파이썬 · TS 짝) · SK 블록 율 줄 = pct
- 굳힘 — 바탕 없는 SK 율 줄은 한 줄로 안 펼침(참조 그대로) · 겹침 때 기준 열쇠 새 차례
- 화면 — [율 줄 추가] 창(새 `M02_MasterTemplete_Sangeun_Rate.ts` · 부품 상자만) · 율 줄 = % 줄로 보임 ·
더한줄 지움 · 되살림 때 기준 열쇠 옮김
- 시험 — 새 test_778_4_rate_line(25) · 거울 헬퍼에 rateBase 길 · 전후 대조 SK 16 · 일위대가 118 · B09 대가 입력 14 같음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -144,6 +144,7 @@ class Expand:
|
||||
for code in pill["로직"]
|
||||
}
|
||||
inner, rows, why, rid = [], [], None, trail[-1] if trail else ""
|
||||
keyed: list[dict] = [] # 율 밑 줄(778-4 · `fixed.rate_base`)
|
||||
for at, one in enumerate(lines):
|
||||
ref = one.get("참조")
|
||||
if isinstance(ref, dict): # 줄 덮개(715-3) 있는 SK 참조 = 그 줄만 다시 셈
|
||||
@@ -166,6 +167,8 @@ class Expand:
|
||||
why = why or f"「{sub.get('이름')}」 {NO_QTY}"
|
||||
continue
|
||||
rows.append(labor.mcut.row({k: v * qty for k, v in sub["기준"].items()}))
|
||||
if not refs.is_rate(head):
|
||||
keyed.append(fixed.rate_row(head, labor.line_id(head), rows[-1]))
|
||||
for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 수량(이은 값)
|
||||
if labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1)
|
||||
continue
|
||||
@@ -190,11 +193,14 @@ class Expand:
|
||||
why = why or f"「{name}」 {inner[-1]['까닭']}"
|
||||
continue
|
||||
rows.append(labor.mcut.row({k: v * qty for k, v in base.items()}))
|
||||
under = labor._sum(rows)
|
||||
for one in inner: # 비율 고정 줄(705-3) — 밑 = 다른 줄 합
|
||||
if (one.get("고정") or {}).get("비율"):
|
||||
one["기준"], row = fixed.ratio_row(one["고정"]["비율"], one["수량"], under)
|
||||
keyed.append({"키": None, "묶음": "재료", "비목": rows[-1]})
|
||||
for one in inner: # 비율 고정 줄(705-3) — 밑 = `rate_base`(778-4)
|
||||
if rate := (one.get("고정") or {}).get("비율"):
|
||||
under = fixed.rate_base(rate["기준"], keyed)
|
||||
one["기준"], row = fixed.ratio_row(rate, one["수량"], under)
|
||||
rows.append(row)
|
||||
if row:
|
||||
keyed.append(fixed.rate_row(one, labor.line_id(one), row, True))
|
||||
return {
|
||||
"이름": doc.get("이름"),
|
||||
"단위": doc.get("단위"),
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
(노임 새 판 때 따라감) · 품(수량)은 그대로.
|
||||
M02 셈(층 · 사본) = 노임 「값」 · 자재 기준단가로 이음 · 기계 = 줄 단가(기계 시간 단가 = B09 단가 책 몫).
|
||||
출처 = `{파일, 호표, 줄, 판}`(글 · 수만) · 줄 가름 = 「고정|이름|규격|단위」(짝 `Labor_Calc.ts` `lineId`).
|
||||
비율 줄(705-3 · 엑셀 % 줄) = `비율 {기준, 비목}` · 수량 = 율(%) — 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100
|
||||
(`ratios` · 짝 `Labor_Calc.ts` `ratioLines` · B09 = `pct` 줄).
|
||||
비율 줄(705-3 · 엑셀 % 줄) = `비율 {기준, 비목}` · 수량 = 율(%) — 반영[비목] = 밑 × 율 ÷ 100
|
||||
(`ratios` · 짝 `Labor_Calc.ts` `ratioLines` · B09 = `pct` 줄) · 단가산출(SK) 율 더한줄(778-4)도 이 꼴.
|
||||
기준(778-4 · 밑 = `rate_base` · 짝 `Labor_Calc.ts` `rateBase`) = 비목(노무비 · 재료비 · 경비 = 율 줄 뺀
|
||||
그 비목 합) · 묶음(인력 · 재료 · 장비 = 그 묶음 줄 노 · 재 · 경 합 · 고정 줄 묶음 = 이음) ·
|
||||
줄 목록 [열쇠](그 줄 합 · 앞 율 줄 넣으면 율 위 율 · SC 열쇠 = 줄 가름 · SK = cid).
|
||||
B09 대가 갈래 `fixed` = `B09_Estimation_Labor_Source.fixed_line` · 셈 `B09_Estimation_Calc_Labor`.
|
||||
"""
|
||||
|
||||
@@ -27,6 +30,8 @@ COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
|
||||
FIXED = "고정"
|
||||
LINKS = {"노임": "LB", "자재": "MT", "기계": "EQ"}
|
||||
SOURCE_KEYS = ("파일", "호표", "줄", "판")
|
||||
GROUPS = {"노임": "인력", "자재": "재료", "기계": "장비"} # 율 기준 묶음(778-4) ← 고정 줄 이음
|
||||
BASES = (*COSTS, *GROUPS.values())
|
||||
|
||||
|
||||
def _bad(text: str) -> Exception:
|
||||
@@ -82,9 +87,9 @@ def clean(fx: Any, where: str, whole: Any) -> dict[str, Any]:
|
||||
link[k] = code or None
|
||||
rate = fx.get("비율")
|
||||
if rate is not None and not (
|
||||
isinstance(rate, dict) and rate.get("기준") in COSTS and rate.get("비목") in COSTS
|
||||
isinstance(rate, dict) and _basis(rate.get("기준")) and rate.get("비목") in COSTS
|
||||
):
|
||||
raise _bad(f"{where} · 고정 줄 비율은 {{기준, 비목}} — 노무비 · 재료비 · 경비")
|
||||
raise _bad(f"{where} · 고정 줄 비율은 {{기준, 비목}} — 기준 = 비목 · 묶음 · 줄 목록")
|
||||
src = fx.get("출처") if isinstance(fx.get("출처"), dict) else {}
|
||||
kept = {k: v for k in SOURCE_KEYS if isinstance(v := src.get(k), (str, int, float))}
|
||||
out = {"이름": name, "규격": str(fx.get("규격") or ""), "단위": str(fx.get("단위") or "")}
|
||||
@@ -92,6 +97,42 @@ def clean(fx: Any, where: str, whole: Any) -> dict[str, Any]:
|
||||
return {**out, "비율": {k: rate[k] for k in ("기준", "비목")}} if rate else out
|
||||
|
||||
|
||||
def _basis(raw: Any) -> bool:
|
||||
"""율 기준 — 비목 · 묶음 이름 · 줄 열쇠 목록(글 · 빈 목록 = 밑 0 · 기준 줄을 지운 뒤)."""
|
||||
if isinstance(raw, list):
|
||||
return all(isinstance(x, str) and x for x in raw)
|
||||
return raw in BASES
|
||||
|
||||
|
||||
def group_of(fx: dict) -> str | None:
|
||||
"""고정 줄 묶음 — 첫 이음(노임 · 자재 · 기계) · 이음 없음 = None."""
|
||||
link = fx.get("이음") or {}
|
||||
return next((g for k, g in GROUPS.items() if link.get(k)), None)
|
||||
|
||||
|
||||
def _all(money: dict | None) -> Decimal:
|
||||
return sum((Decimal(str((money or {}).get(k) or 0)) for k in COSTS), Decimal(0))
|
||||
|
||||
|
||||
def rate_base(basis: Any, rows: list[dict]) -> Decimal:
|
||||
"""율 줄 밑(778-4 · 짝 `Labor_Calc.ts` `rateBase`) — `rows` [{키, 묶음, 비목 {노 · 재 · 경}, 율?}] ·
|
||||
비목 = 율 줄 뺀 그 비목 합 · 묶음 = 율 줄 뺀 그 묶음 줄 셋 합 · 목록 = 그 열쇠 줄 셋 합."""
|
||||
if isinstance(basis, list):
|
||||
keep = set(basis)
|
||||
return sum((_all(r["비목"]) for r in rows if r.get("키") in keep), Decimal(0))
|
||||
rows = [r for r in rows if not r.get("율")]
|
||||
if basis in COSTS:
|
||||
return sum((Decimal(str((r["비목"] or {}).get(basis) or 0)) for r in rows), Decimal(0))
|
||||
return sum((_all(r["비목"]) for r in rows if r.get("묶음") == basis), Decimal(0))
|
||||
|
||||
|
||||
def rate_row(one: dict, key: Any, money: dict | None, rate: bool = False) -> dict[str, Any]:
|
||||
"""`rate_base` 줄 하나 — 고정 줄 묶음 = 이음 · 그 밖 = 줄 `묶음`(SK 엔진 줄 · 자재 = 재료)."""
|
||||
fx = one.get(FIXED)
|
||||
group = group_of(fx) if isinstance(fx, dict) else one.get("묶음")
|
||||
return {"키": key, "묶음": group, "비목": money, "율": rate}
|
||||
|
||||
|
||||
def _has(whole: Any, code: str) -> bool:
|
||||
try:
|
||||
return bool(whole.get(code))
|
||||
@@ -136,26 +177,35 @@ def line(whole: Any, fx: dict) -> dict[str, Any]:
|
||||
return {**head, "기준": money, "이은": used, "까닭": None}
|
||||
|
||||
|
||||
def ratio_row(rate: dict, qty: Decimal | None, under: dict) -> tuple[dict, dict | None]:
|
||||
"""비율 줄 (기준, 반영) — 기준[비목] = 밑[기준] · 반영 = 기준 × 율 ÷ 100 ·
|
||||
def ratio_row(rate: dict, qty: Decimal | None, under: Decimal) -> tuple[dict, dict | None]:
|
||||
"""비율 줄 (기준, 반영) — 기준[비목] = 밑(`rate_base`) · 반영 = 기준 × 율 ÷ 100 ·
|
||||
0.1 원 버림(751-3) · 수량 없음 = None."""
|
||||
base = dict.fromkeys((*COSTS, "계"), Decimal(0))
|
||||
base[rate["비목"]] = base["계"] = under[rate["기준"]]
|
||||
base[rate["비목"]] = base["계"] = under
|
||||
return base, None if qty is None else mcut.row({k: base[k] * qty / 100 for k in COSTS})
|
||||
|
||||
|
||||
def ratios(lines: list[dict], reflects: list[dict | None], bases: list[dict | None]) -> None:
|
||||
"""비율 줄(705-3) — `settle` 끝에 · 밑 = 비율 줄 뺀 반영합(줄 · 자재) · 배 100 · 기준합엔 안 셈 ·
|
||||
`lines` · `reflects` · `bases` 앞 줄 = 같은 차례(짝 `Labor_Calc.ts` `ratioLines`)."""
|
||||
at = {i for i, x in enumerate(lines) if (x.get(FIXED) or {}).get("비율")}
|
||||
under = {
|
||||
k: sum((r[k] for i, r in enumerate(reflects) if r and i not in at), Decimal(0))
|
||||
for k in COSTS
|
||||
}
|
||||
for i in sorted(at):
|
||||
base, done = ratio_row(lines[i][FIXED]["비율"], lines[i].get("수량"), under)
|
||||
"""비율 줄(705-3) — `settle` 끝에 · 밑 = `rate_base`(율 줄 뺀 반영(줄 · 자재) · 앞 율 줄은 목록만) ·
|
||||
배 100 · 기준합엔 안 셈 · `lines` · `reflects` · `bases` 앞 줄 = 같은 차례(짝 `Labor_Calc.ts`
|
||||
`ratioLines`) · 자재 줄 = 묶음 재료 · 열쇠 없음."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
at = [i for i, x in enumerate(lines) if (x.get(FIXED) or {}).get("비율")]
|
||||
rows = [
|
||||
rate_row(lines[i], labor.line_id(lines[i]), r)
|
||||
if i < len(lines)
|
||||
else {"키": None, "묶음": "재료", "비목": r}
|
||||
for i, r in enumerate(reflects)
|
||||
if r and i not in at
|
||||
]
|
||||
for i in at:
|
||||
rate = lines[i][FIXED]["비율"]
|
||||
base, done = ratio_row(rate, lines[i].get("수량"), rate_base(rate["기준"], rows))
|
||||
lines[i], reflects[i], bases[i] = (
|
||||
{**lines[i], "기준": base, "배": 100, "반영": done},
|
||||
done,
|
||||
None,
|
||||
)
|
||||
if done:
|
||||
rows.append(rate_row(lines[i], labor.line_id(lines[i]), done, True))
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
`{층: 사본, 주인: 몸 지문, 키: 본 SC, 몸}` 에 담음(`M02_Labor_Ref.copy_ref` · 원본
|
||||
연결 없음 · 하위 호표 끝수 단계 · B09 하위 대가 블록 그대로 · 같은 기초 겹침도
|
||||
블록 안)
|
||||
바탕 SK 참조 · 패치 있는 SK 참조 줄 = 위 덮개를 아래 덮개에 겹쳐 한 벌(`compose`)
|
||||
바탕 SK 참조 · 패치 있는 SK 참조 줄 = 위 덮개를 아래 덮개에 겹쳐 한 벌(`compose` · 율 기준 줄 열쇠
|
||||
`add:#k` 도 새 차례로 · 778-4)
|
||||
율 줄(778-4) 있는 바탕 없는 SK = 한 줄로 못 펼침(율 · 기준 묶음이 넣는 쪽 줄로 번짐) → 참조 그대로
|
||||
못 펼친 줄 = 저장 · 읽기 · 옮기기에선 옛 참조 그대로(값 그대로) · 넣기 길(`strict`)에선 400 까닭.
|
||||
초기화(`reset`) = 덮개 · 뺀줄 · 더한줄 단가 걷음(안쪽 넣은 몸 · 사본 블록까지) · 이음 기계 고정 줄
|
||||
단가 = M01 기계 한 시간 값 다시 받음(736-3) · 항목 · 수량 · 입력값 · 고름 · 자재표시 · 켜쌓기율 ·
|
||||
@@ -142,6 +144,8 @@ def sk_adds(y: dict, q: Decimal, whole: Any, tag: dict) -> list[dict]:
|
||||
|
||||
adds = filled_adds(y, whole)
|
||||
code = (y.get("바탕") or {}).get("로직")
|
||||
if not code and any(sd.is_rate(one) for one in adds):
|
||||
raise sd.DocError("율 줄 있는 바탕 없는 단가산출은 펼쳐 넣지 못함 — 참조로 둠")
|
||||
if not code:
|
||||
return [_scaled(one, q, tag) for one in adds]
|
||||
row = whole.logic(code)
|
||||
@@ -291,13 +295,36 @@ def compose(under: dict, over: dict, whole: Any) -> dict:
|
||||
into[t[1]] = value
|
||||
rate = over.get("켜쌓기율", under.get("켜쌓기율"))
|
||||
out = {**over, "바탕": base, "덮개": covers, "뺀줄": drop, "고름": picks, "자재표시": marks}
|
||||
out["더한줄"] = [a for k, a in enumerate(adds) if k not in gone] + list(
|
||||
over.get("더한줄") or []
|
||||
)
|
||||
kept = [k for k in range(len(adds)) if k not in gone]
|
||||
new = {f"add:#{k}": f"add:#{n}" for n, k in enumerate(kept)} # 율 기준 열쇠(778-4)
|
||||
mine = [
|
||||
_rekey(a, lambda x: new.get(x) if ADD.match(x) else x)
|
||||
for k, a in enumerate(adds)
|
||||
if k not in gone
|
||||
]
|
||||
|
||||
def up(cid: str) -> str | None: # 위 문서 열쇠 → 한 벌 열쇠
|
||||
hit = ADD.match(cid)
|
||||
if hit:
|
||||
return f"add:#{len(kept) + int(hit[1])}"
|
||||
t = to(cid)
|
||||
return cid if t is None else t[1] if t[0] == "base" else new.get(f"add:#{t[1]}")
|
||||
|
||||
out["더한줄"] = mine + [_rekey(dict(a), up) for a in over.get("더한줄") or []]
|
||||
out["입력값"] = {**(under.get("입력값") or {}), **(over.get("입력값") or {})}
|
||||
return out | ({"켜쌓기율": rate} if rate is not None else {})
|
||||
|
||||
|
||||
def _rekey(one: dict, move: Any) -> dict:
|
||||
"""율 더한줄 기준 줄 목록 열쇠를 `move` 로(못 옮긴 열쇠 = 버림 · 778-4)."""
|
||||
rate = ((one.get("고정") or {}).get("비율") or {}) if isinstance(one, dict) else {}
|
||||
if not isinstance(rate.get("기준"), list):
|
||||
return one
|
||||
keys = [k for k in (move(x) for x in rate["기준"]) if k]
|
||||
fx = {**one["고정"], "비율": {**rate, "기준": keys}}
|
||||
return {**one, "고정": fx}
|
||||
|
||||
|
||||
# ── SC ────────────────────────────────────────────────────────────────
|
||||
def sk_line(y: dict, one: dict, whole: Any, tag: dict) -> dict:
|
||||
"""펼친 SK → 일위대가 로직 줄 하나(+ 패치) — 옛 참조 줄 `one` 의 별칭 · 메모 · 수량 · 패치
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`).
|
||||
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`) ·
|
||||
* 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가.
|
||||
* 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`). */
|
||||
* 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`).
|
||||
* 율 줄 밑(778-4) `rateBase` = 짝 `M02_Labor_Fixed.rate_base` — 일위대가 비율 줄 · 단가산출(SK) 율 더한줄
|
||||
* (서버 `M02_Sangeun_Calc._rated`) 한 규칙 · 거울 시험 `resources/tester/test_778_4_rate_line.py`. */
|
||||
|
||||
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
|
||||
@@ -36,10 +38,25 @@ export interface LaborFixed {
|
||||
단가: Record<"노무비" | "재료비" | "경비", number>;
|
||||
이음?: Record<"노임" | "자재" | "기계", string | null>;
|
||||
출처?: Record<string, string | number>;
|
||||
/** 비율 줄(705-3 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100 */
|
||||
비율?: { 기준: CostKey; 비목: CostKey };
|
||||
/** 비율 줄(705-3 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 밑(`rateBase`) × 율 ÷ 100 */
|
||||
비율?: { 기준: RateBasis; 비목: CostKey };
|
||||
}
|
||||
type CostKey = "노무비" | "재료비" | "경비";
|
||||
/** 율 기준(778-4) — 비목 · 묶음(인력 · 재료 · 장비) · 줄 열쇠 목록 */
|
||||
export type RateBasis = CostKey | RateGroup | string[];
|
||||
export type RateGroup = "인력" | "재료" | "장비";
|
||||
/** 율 밑 줄 — 열쇠(SC 줄 가름 · SK cid) · 묶음 · 비목 · 율 줄인가 */
|
||||
export interface RateRow {
|
||||
키: string | null;
|
||||
묶음: string | null;
|
||||
비목: Partial<Record<CostKey, number | null>> | null;
|
||||
율?: boolean;
|
||||
}
|
||||
const GROUPS: Record<"노임" | "자재" | "기계", RateGroup> = {
|
||||
노임: "인력",
|
||||
자재: "재료",
|
||||
기계: "장비",
|
||||
};
|
||||
|
||||
export interface CalcLine {
|
||||
/** 참조 줄은 서버 답에 없음(빈 글로 다룸) */
|
||||
@@ -234,27 +251,69 @@ export function settle(
|
||||
if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost });
|
||||
return { ...one, 수량: qty, 수량출처: source, 반영: cost };
|
||||
});
|
||||
const lines = ratioLines(줄, done, base);
|
||||
const lines = ratioLines(
|
||||
줄,
|
||||
자재.filter((m) => !aside.has(m.코드)),
|
||||
done,
|
||||
base,
|
||||
);
|
||||
return { 줄: lines, 자재, 기준합: base, 반영합: cutSums(done) };
|
||||
}
|
||||
|
||||
/** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = 비율 줄 뺀 반영합 · 기준[비목] = 밑[기준] ·
|
||||
* 배 100 · 반영 = 기준 × 율 ÷ 100 · `done` 에 더함 · 기준합(`base`)엔 안 셈 */
|
||||
function ratioLines(lines: CalcLine[], done: Money, base: Money): CalcLine[] {
|
||||
/** 고정 줄 묶음 — 첫 이음(노임 · 자재 · 기계 · 짝 `M02_Labor_Fixed.group_of`) */
|
||||
export function groupOf(fx: LaborFixed | undefined): RateGroup | null {
|
||||
const link = fx?.이음;
|
||||
const hit = (Object.keys(GROUPS) as (keyof typeof GROUPS)[]).find((k) => link?.[k]);
|
||||
return hit ? GROUPS[hit] : null;
|
||||
}
|
||||
|
||||
const all = (m: RateRow["비목"]): number => COSTS.reduce((s, k) => s + (m?.[k] ?? 0), 0);
|
||||
|
||||
/** 율 줄 밑(778-4 · 짝 `M02_Labor_Fixed.rate_base`) — 비목 = 율 줄 뺀 그 비목 합 ·
|
||||
* 묶음 = 율 줄 뺀 그 묶음 줄 셋 합 · 목록 = 그 열쇠 줄 셋 합(앞 율 줄 됨) */
|
||||
export function rateBase(basis: RateBasis, rows: RateRow[]): number {
|
||||
if (Array.isArray(basis)) {
|
||||
const keep = new Set(basis);
|
||||
return rows.reduce((s, r) => s + (r.키 !== null && keep.has(r.키) ? all(r.비목) : 0), 0);
|
||||
}
|
||||
const plain = rows.filter((r) => !r.율);
|
||||
if ((COSTS as readonly string[]).includes(basis))
|
||||
return plain.reduce((s, r) => s + (r.비목?.[basis as CostKey] ?? 0), 0);
|
||||
return plain.reduce((s, r) => s + (r.묶음 === basis ? all(r.비목) : 0), 0);
|
||||
}
|
||||
|
||||
/** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = `rateBase`(율 줄 뺀 반영 줄 · 자재 = 묶음 재료) ·
|
||||
* 기준[비목] = 밑 · 배 100 · 반영 = 기준 × 율 ÷ 100 · `done` 에 더함 · 기준합(`base`)엔 안 셈 */
|
||||
function ratioLines(
|
||||
lines: CalcLine[],
|
||||
mats: { 반영?: number | null }[],
|
||||
done: Money,
|
||||
base: Money,
|
||||
): CalcLine[] {
|
||||
for (const l of lines.filter((x) => x.고정?.비율)) {
|
||||
for (const k of MONEY_KEYS) {
|
||||
done[k] -= l.반영?.[k] ?? 0;
|
||||
base[k] -= l.기준?.[k] ?? 0;
|
||||
}
|
||||
}
|
||||
const under = { ...done };
|
||||
const rows: RateRow[] = [
|
||||
...lines
|
||||
.filter((l) => !l.고정?.비율 && l.반영)
|
||||
.map((l) => ({ 키: lineId(l) ?? null, 묶음: groupOf(l.고정), 비목: l.반영 ?? null })),
|
||||
...mats
|
||||
.filter((m) => m.반영 != null)
|
||||
.map((m) => ({ 키: null, 묶음: "재료", 비목: { 재료비: m.반영 } })),
|
||||
];
|
||||
return lines.map((line) => {
|
||||
const rate = line.고정?.비율;
|
||||
if (!rate) return line;
|
||||
const own = zero();
|
||||
own[rate.비목] = own.계 = under[rate.기준];
|
||||
own[rate.비목] = own.계 = rateBase(rate.기준, rows);
|
||||
const got = reflect(own, line.수량 ?? null, 100);
|
||||
if (got) for (const k of MONEY_KEYS) done[k] += got[k];
|
||||
if (got) {
|
||||
for (const k of MONEY_KEYS) done[k] += got[k];
|
||||
rows.push({ 키: lineId(line) ?? null, 묶음: groupOf(line.고정), 비목: got, 율: true });
|
||||
}
|
||||
return { ...line, 기준: own, 배: 100, 반영: got };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead";
|
||||
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
|
||||
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
|
||||
import { openRateLine } from "./M02_MasterTemplete_Sangeun_Rate";
|
||||
import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side";
|
||||
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
|
||||
import {
|
||||
@@ -121,6 +122,18 @@ export function mountSangeunDetail(
|
||||
}),
|
||||
});
|
||||
addButton.hidden = !edit;
|
||||
// 778-4 율 줄 — 기준 줄 후보 = 지금 셈 답 줄(세부 줄 뺌) · 새 줄은 맨 끝(앞 줄만 가리킴)
|
||||
const rateButton = createButton({
|
||||
label: L("M02_SkRateAdd"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
openRateLine(
|
||||
(ans?.줄 ?? []).map((l) => ({ cid: l.cid, 이름: l.이름 })),
|
||||
(line) => ((doc.더한줄 = [...(doc.더한줄 ?? []), withFirst(line)]), touch()),
|
||||
),
|
||||
});
|
||||
rateButton.hidden = !edit;
|
||||
/** 둘째 기초(728-2) — 수량 처음값 = 바탕 기준단위 1 개 = 그 기초 기준단위 몇 개(같은 성격 · 다르면 1) */
|
||||
async function addBase(line: { 로직: string; 수량: number }): Promise<void> {
|
||||
let qty = 1;
|
||||
@@ -153,7 +166,7 @@ export function mountSangeunDetail(
|
||||
children: [
|
||||
head,
|
||||
notes,
|
||||
el("div", { className: "m02-sk__head", children: [views.root, addButton] }),
|
||||
el("div", { className: "m02-sk__head", children: [views.root, addButton, rateButton] }),
|
||||
editor,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_Rate.ts
|
||||
* M02 단가산출(SK) 율 줄 넣기 창(PLAN 778-4 · 요구 623) — [율 줄 추가] → 이름 · 율 % · 기준 · 금액이 드는 칸.
|
||||
* 기준 = 묶음(인력 · 재료 · 장비 줄 합) · 비목 전체(노무비 · 재료비 · 경비) · 줄 고르기(이 SK 줄 cid 목록 ·
|
||||
* 앞 율 줄 고르면 율 위 율) → 더한줄 `{고정: {이름, 단위 %, 단가 0, 이음 없음, 비율 {기준, 비목}}, 수량 = 율}`
|
||||
* (꼴 = 서버 `M02_Sangeun_Doc` · 셈 = 서버 `M02_Sangeun_Calc._rated`) · 부품 상자만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createFormGrid } from "@ui/ui_template_general_layout";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { SkAdded } from "./logic/M02_Sangeun_Cover_Model";
|
||||
|
||||
const LINES = "줄";
|
||||
|
||||
/** 율 줄 넣기 — `rows` = 지금 셈 답 줄(기준 줄 고르기 후보) · `done` = 새 더한줄 한 줄 */
|
||||
export function openRateLine(
|
||||
rows: { cid: string; 이름: string }[],
|
||||
done: (line: SkAdded) => void,
|
||||
): void {
|
||||
const name = createInputField({ label: L("M02_SkRateName"), value: L("M02_SkRateDefault") });
|
||||
const rate = createInputField({
|
||||
label: L("M02_SkRateRate"),
|
||||
type: "number",
|
||||
value: "2",
|
||||
unit: "%",
|
||||
min: 0,
|
||||
});
|
||||
const picks = rows.map((r) => ({
|
||||
cid: r.cid,
|
||||
box: createCheckbox(r.이름 || r.cid, { chip: true, size: 28 }),
|
||||
}));
|
||||
const list = el("div", {
|
||||
className: "m02-sk__tags",
|
||||
attrs: { "data-sk-rate-lines": "" },
|
||||
children: picks.length
|
||||
? picks.map((p) => p.box.root)
|
||||
: [el("span", { text: L("M02_SkRateNoLines") })],
|
||||
});
|
||||
list.hidden = true;
|
||||
const basis = createSelectField({
|
||||
label: L("M02_SkRateBasis"),
|
||||
value: "인력",
|
||||
options: [
|
||||
{ value: "인력", text: L("M02_SkRateGroupLabor") },
|
||||
{ value: "재료", text: L("M02_SkRateGroupMat") },
|
||||
{ value: "장비", text: L("M02_SkRateGroupMach") },
|
||||
{ value: "노무비", text: L("M02_SkRateCostLabor") },
|
||||
{ value: "재료비", text: L("M02_SkRateCostMat") },
|
||||
{ value: "경비", text: L("M02_SkRateCostExp") },
|
||||
{ value: LINES, text: L("M02_SkRateBasisLines") },
|
||||
],
|
||||
onChange: (v) => (list.hidden = v !== LINES),
|
||||
});
|
||||
const part = createSelectField({
|
||||
label: L("M02_SkRatePart"),
|
||||
value: "경비",
|
||||
options: [
|
||||
{ value: "경비", text: L("M02_SkRatePartExp") },
|
||||
{ value: "재료비", text: L("M02_SkRatePartMat") },
|
||||
{ value: "노무비", text: L("M02_SkRatePartLabor") },
|
||||
],
|
||||
});
|
||||
openModal({
|
||||
title: L("M02_SkRateTitle"),
|
||||
closeLabel: L("M02_SkRateClose"),
|
||||
width: "40rem",
|
||||
mount: (body, close, actions) => {
|
||||
const ok = createButton({
|
||||
label: L("M02_SkRateOk"),
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
const label = name.input.value.trim();
|
||||
const q = Number(rate.input.value);
|
||||
const keys = picks.filter((p) => p.box.input.checked).map((p) => p.cid);
|
||||
const lines = basis.select.value === LINES;
|
||||
name.setError(label ? undefined : L("M02_SkRateNeedName"));
|
||||
rate.setError(rate.input.value.trim() && q >= 0 ? undefined : L("M02_SkRateBadRate"));
|
||||
if (lines && !keys.length) return void rate.setError(L("M02_SkRateNeedLine"));
|
||||
if (!label || !rate.input.value.trim() || !(q >= 0)) return;
|
||||
const fx = {
|
||||
이름: label,
|
||||
단위: "%",
|
||||
단가: { 노무비: 0, 재료비: 0, 경비: 0 },
|
||||
이음: { 노임: null, 자재: null, 기계: null },
|
||||
비율: { 기준: lines ? keys : basis.select.value, 비목: part.select.value },
|
||||
};
|
||||
done({ 고정: fx, 이름: label, 단위: "%", 수량: q });
|
||||
close();
|
||||
},
|
||||
});
|
||||
ok.dataset.skRateOk = "";
|
||||
actions.append(ok);
|
||||
const grid = createFormGrid([name.root, rate.root, basis.root, part.root]);
|
||||
grid.dataset.skRate = "";
|
||||
body.append(grid, list);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,9 @@ SK 단위 환산(728-2) = 배 = 바탕 기준단위(「100본」) → SK 단위(
|
||||
일위대가 덮개 줄(`line` · `M02_Labor_Patch.run`)은 일위대가 기본(222-1 · 162-1) 그대로.
|
||||
보기 넷 한 답(750-1) = 화면 셈 길 답에 `글`(읽는 식) · `중간`(`M02_Sangeun_Views`) · 일위대가 줄
|
||||
모달 = 문서 `줄모드` true → `line`(일위대가 그 줄 셈과 같은 값).
|
||||
율 더한줄(778-4 · `_rated`) = 엔진 한 번 돈 줄로 밑(`M02_Labor_Fixed.rate_base` · 짝 화면
|
||||
`M02_MasterTemplete_Labor_Calc.ts` `rateBase` · 거울 시험 `test_778_4_rate_line.py`) → 율 줄 `값묶음` =
|
||||
{비목: 밑 ÷ 100} → 엔진 다시(율 줄 없는 문서 = 한 번만 · 값 그대로) · 줄 덧칸 `율` · `규격`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -350,6 +353,10 @@ def run(
|
||||
names = {str(i.get("이름")) for i in row.get("입력") or []}
|
||||
values = {k: v for k, v in {**got["입력값"], **(given or {})}.items() if k in names}
|
||||
out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None)
|
||||
rates = _rated(row, out.get("줄") or [], got["정체"], got["자재표시"], got["켜쌓기율"])
|
||||
if rates is not None: # 율 더한줄(778-4) — 밑을 넣고 다시
|
||||
head["짝잃음"] = head["짝잃음"] + rates
|
||||
out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None)
|
||||
eng = out.get("줄") or []
|
||||
seen = (
|
||||
views.text_of(over, key, values, got["고름"], got["정체"], eng) if detail and seen else {}
|
||||
@@ -365,6 +372,10 @@ def run(
|
||||
one = got["채운더한줄"][int(hit[1])] if hit else {}
|
||||
if detail and one.get("종류") == "로직" and at not in gaps:
|
||||
mark |= _detail(one, int(hit[1]), whole)
|
||||
if sd.is_rate(one): # 율 줄 = 율 · 「기준의 %」(화면 % 줄 · `per_unit` 수량 그대로)
|
||||
rate = one["고정"]["비율"]
|
||||
group = {"노무비": "인력", "재료비": "재료", "경비": "경비"}[rate["비목"]]
|
||||
mark |= {"율": Decimal(str(one["수량"])), "규격": _rate_text(rate), "묶음": group}
|
||||
lines.append(
|
||||
{**line, "cid": cid, **mark, **({"원값": mine["원값"]} if "원값" in mine else {})}
|
||||
)
|
||||
@@ -380,6 +391,58 @@ def run(
|
||||
return {**out, **head, **seen, "1단위": unit, "빠진줄": gone, "꼬리표": tag}
|
||||
|
||||
|
||||
def _group(item: dict, line: dict) -> str | None:
|
||||
"""율 밑 묶음(778-4) — 인력 · 재료 · 장비(기계경비 로직 · 기계 이음 고정 · 기계 줄) · 그 밖 None."""
|
||||
if isinstance(item.get("고정"), dict):
|
||||
return fixed.group_of(item["고정"])
|
||||
if line.get("장비"):
|
||||
return "장비"
|
||||
return {"인력": "인력", "재료": "재료", "기계": "장비"}.get(str(item.get("종류")))
|
||||
|
||||
|
||||
def _rated(row: dict, eng: list, ids: list, marks: dict, stack: Any = None) -> list[dict] | None:
|
||||
"""율 더한줄(778-4) `값묶음` = {비목: 밑 ÷ 100} — 한 번 돈 엔진 줄(`eng` · 정체 `ids`)로
|
||||
`fixed.rate_base`(별도계상 재료 뺌 · 앞 율 줄 차례로 · 켜쌓기율 몫 뗌) · 제자리 · 율 줄 없음 = None ·
|
||||
돌려줌 = 짝잃음(없는 기준 줄)."""
|
||||
items = row["호표"]
|
||||
at = [i for i, x in enumerate(items) if (x.get("고정") or {}).get("비율")]
|
||||
if not at:
|
||||
return None
|
||||
rows = [
|
||||
{"키": cid, "묶음": _group(items[i] if i < len(items) else {}, x), "비목": x.get("비목")}
|
||||
for i, (cid, x) in enumerate(zip(ids, eng))
|
||||
if i not in at
|
||||
and not (
|
||||
i < len(items) and items[i].get("종류") == "재료" and labor.apart(marks.get(str(i)))
|
||||
)
|
||||
]
|
||||
times = Decimal(str(stack)) / 100 if stack not in (None, 100) else Decimal(1)
|
||||
lost = []
|
||||
for i in at:
|
||||
rate, qty = items[i]["고정"]["비율"], Decimal(str(items[i]["수량"]))
|
||||
if isinstance(rate["기준"], list):
|
||||
lost += [
|
||||
{"cid": ids[i], "까닭": f"율 기준 줄 없음 「{k}」"}
|
||||
for k in rate["기준"] or [""]
|
||||
if k not in ids
|
||||
]
|
||||
base = fixed.rate_base(rate["기준"], rows) / times
|
||||
items[i] = {
|
||||
**items[i],
|
||||
"값묶음": {**dict.fromkeys(COSTS, Decimal(0)), rate["비목"]: base / 100},
|
||||
}
|
||||
rows.append(
|
||||
{"키": ids[i], "묶음": None, "비목": {rate["비목"]: base * qty / 100}, "율": True}
|
||||
)
|
||||
return lost
|
||||
|
||||
|
||||
def _rate_text(rate: dict) -> str:
|
||||
"""율 줄 규격 — 「인력의 %」 · 목록 = 「줄 n 개의 %」."""
|
||||
basis = rate["기준"]
|
||||
return f"줄 {len(basis)} 개의 %" if isinstance(basis, list) else f"{basis}의 %"
|
||||
|
||||
|
||||
def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]:
|
||||
"""펼침(`Expand`) 한 칸 — `{이름, 단위, 기준(1 단위), 까닭, 줄, 짝잃음}` · 못 셈 = 까닭."""
|
||||
head = {"이름": doc.get("이름"), "단위": doc.get("단위")}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
| {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?,
|
||||
고름? · 자재표시? · 켜쌓기율? · 더한줄?(넣은 SK 몸 · 740-1) · 본뜬?(출처)}
|
||||
| {참조 | 고정, 이름?, 단위?, 수량}],
|
||||
(율 줄 778-4 = 고정 `비율 {기준, 비목}` · 수량 = 율 % · 단위 % · 기준 = 비목 · 묶음 인력 · 재료 · 장비 ·
|
||||
줄 열쇠 목록 [cid | add:#k] — 더한줄 율 줄은 앞 줄만 · 셈 `M02_Sangeun_Calc._rated`)
|
||||
뺀줄 [cid], 바탕판, 환산?}
|
||||
SK 단위 환산(728-2) = `환산` {계수: 양수}(성격 다름 · 바탕 앞 수 뗀 1 단위 = SK 몇 단위) |
|
||||
{단위중량: {종별, 형상}}(부피 ↔ 무게 · 표 CC000337) — 셈 = `M02_Sangeun_Calc.ratio_of`.
|
||||
@@ -168,8 +170,8 @@ def _added(raw: Any, where: str, whole: Any) -> dict:
|
||||
head = {"고정": fixed.clean(raw["고정"], where, whole) if whole else raw["고정"]}
|
||||
except Exception as e: # noqa: BLE001 — 고정 줄 검사 = StoreError(400)
|
||||
raise DocError(str(getattr(e, "detail", e))) from e
|
||||
if head["고정"].get("비율"):
|
||||
raise DocError(f"{where} · 비율 고정 줄은 단가산출에 못 담음 — 덧줄 율로")
|
||||
if head["고정"].get("비율"): # 율 줄(778-4)
|
||||
unit = unit or "%"
|
||||
elif raw.get("종류") == "로직":
|
||||
code = str(raw.get("로직") or "")
|
||||
row = _logic(whole, code, where)
|
||||
@@ -339,6 +341,7 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str
|
||||
out["더한줄"] = [
|
||||
_added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1)
|
||||
]
|
||||
_rate_keys(out["더한줄"])
|
||||
drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []]
|
||||
if len(set(drop)) != len(drop):
|
||||
raise DocError("뺀줄에 같은 줄 두 번")
|
||||
@@ -348,6 +351,25 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str
|
||||
return out
|
||||
|
||||
|
||||
def is_rate(one: Any) -> bool:
|
||||
"""율 더한줄(778-4) — 고정 `비율`."""
|
||||
return isinstance(one, dict) and bool((one.get("고정") or {}).get("비율"))
|
||||
|
||||
|
||||
def _rate_keys(adds: list[dict]) -> None:
|
||||
"""율 더한줄 기준 목록(778-4) — 열쇠 = 바탕 cid · `add:#k` · 자기 · 뒤 율 줄 못 가리킴(율 위 율 =
|
||||
앞 율 줄만 · 순환 없음)."""
|
||||
for at, one in enumerate(adds):
|
||||
basis = ((one.get("고정") or {}).get("비율") or {}).get("기준")
|
||||
for key in basis if isinstance(basis, list) else []:
|
||||
hit = re.match(r"^add:#(\d+)$", key)
|
||||
if not hit and not _CID.match(key):
|
||||
raise DocError(f"더한줄 {at + 1}째 · 율 기준 줄 열쇠 꼴 아님 「{key}」")
|
||||
k = int(hit[1]) if hit else -1
|
||||
if k == at or (k > at and k < len(adds) and is_rate(adds[k])):
|
||||
raise DocError(f"더한줄 {at + 1}째 · 율 기준에 자기 · 뒤 율 줄 「{key}」")
|
||||
|
||||
|
||||
def _swap_how(raw: Any) -> dict[str, Any]:
|
||||
"""단위 환산 칸(728-2) — 계수(양수) · 단위중량 {종별, 형상} 중 있는 것만."""
|
||||
if not isinstance(raw, dict):
|
||||
|
||||
@@ -257,7 +257,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
|
||||
const one = added === null ? undefined : doc.더한줄?.[added];
|
||||
const owner = at ? doc.더한줄?.[at.k] : undefined;
|
||||
const cover = at ? owner?.덮개?.[at.cid] : added === null ? doc.덮개?.[cid] : undefined;
|
||||
const pct = (at?.cid ?? cid).startsWith("pct:");
|
||||
const pct = (at?.cid ?? cid).startsWith("pct:") || extra.율 !== undefined; // 덧줄 · 율 줄(778-4)
|
||||
const keys = Object.keys(parts);
|
||||
const shown = added === null && !at ? (was.get(cid) ?? null) : null;
|
||||
const base = at
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* M02_MasterTemplete/logic/M02_Sangeun_Undo.ts
|
||||
* 단가산출(SK) 되돌리기 기억(PLAN 744-1 서버 · 748-0 화면) — 더한 기초 · 더한 항목 빼기 → 문서 `뺀더한줄`
|
||||
* `[{차례, 줄}]`(되살림 = 그 차례에 다시 끼움) · 더한 항목 수량 → `처음수량`(넣을 때 값) 되돌림.
|
||||
* 더한줄 지움 · 끼움 때 뒤 더한줄 열쇠 `add:#j[/…]`(덮개 · 고름 · 자재표시)를 한 칸씩 당김 · 밂.
|
||||
* 더한줄 지움 · 끼움 때 뒤 더한줄 열쇠 `add:#j[/…]`(덮개 · 고름 · 자재표시 · 율 줄 기준 목록 778-4)를
|
||||
* 한 칸씩 당김 · 밂(지운 줄을 가리킨 기준 열쇠는 버림 — 빈 목록 = 서버 짝잃음 알림).
|
||||
* 금액은 늘 서버가 셈 — 여기는 문서 칸 옮김만 · 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -32,6 +33,17 @@ function moveKeys(doc: SkDoc, mode: { drop: number } | { push: number }): void {
|
||||
}
|
||||
(doc as Record<string, unknown>)[name] = out;
|
||||
}
|
||||
for (const one of doc.더한줄 ?? []) {
|
||||
const rate = (one.고정 as { 비율?: { 기준?: unknown } } | undefined)?.비율;
|
||||
if (!rate || !Array.isArray(rate.기준)) continue;
|
||||
rate.기준 = (rate.기준 as string[]).flatMap((key) => {
|
||||
const hit = KEY_RE.exec(key);
|
||||
if (!hit) return [key];
|
||||
const j = Number(hit[1]);
|
||||
if ("drop" in mode) return j === mode.drop ? [] : [j > mode.drop ? `add:#${j - 1}` : key];
|
||||
return [j >= mode.push ? `add:#${j + 1}` : key];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 더한줄 k 를 빼고 기억에 둠 — 이름은 되살림 띠 글(서버가 저장 때 다시 적음) */
|
||||
|
||||
Reference in New Issue
Block a user