fix(b09): 조종원 시간당 노임을 식 좌→우 순차 + 절사로 — 골든셋 시험 첫 벌
- 계수로 미리 접으면 267,360 → 55,699 로 1원 틀림 — 식 1/8*16/12*25/20 을 차례로 풀어 55,700 - 중기 호표 운전원 줄은 시간당 노임 제목(#시간) × 인수로 부름(STmate 운전원 1 × 55,700 모양) - 골든셋 시험 test_b09_golden_stmate — 실무 6건 환율및기초자료 시간당 칸 전수 재현 (다섯 건 원 미만 절사 · 2025 울진 소광 0.1원 미만 절사 — 자리는 설정값) - 28자리 끝 반올림 차례만 다른 합 비교 시험을 허용 오차로 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -31,7 +31,11 @@ from B09_Estimation.B09_Estimation_Rounding import (
|
||||
OutputPlace,
|
||||
round_at,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import (
|
||||
HOURLY_WAGE_SUFFIX,
|
||||
UnitPriceBuild,
|
||||
cached_build,
|
||||
)
|
||||
|
||||
_ZERO = Decimal(0)
|
||||
|
||||
@@ -55,7 +59,8 @@ def catalog_list(build: UnitPriceBuild, kind: PriceKind) -> list[dict[str, Any]]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for code in sorted(build.book.titles):
|
||||
title = build.book.titles[code]
|
||||
if title.kind is not kind:
|
||||
# 조종원 시간당(`…#시간`)은 기초단가가 아니라 일당에서 **셈한** 값 — 노무비목록표엔 안 실음.
|
||||
if title.kind is not kind or code.endswith(HOURLY_WAGE_SUFFIX):
|
||||
continue
|
||||
try:
|
||||
price: Decimal | None = title.adopted_price()
|
||||
|
||||
@@ -31,8 +31,8 @@ from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_MachineCost import (
|
||||
OPERATOR_ALLOWANCE_FACTOR,
|
||||
OPERATOR_ALLOWANCE_NOTICE,
|
||||
hourly_operator_wage,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
||||
@@ -169,7 +169,8 @@ def base_reference_data(
|
||||
"day_wage_krw": _money(Decimal(str(wage))),
|
||||
# 제수당·상여금·퇴직급여충당금 계수를 곱한 값 (2026-09-09 사용자 확정 ③).
|
||||
# 근거·한계는 `MachineCost.OPERATOR_ALLOWANCE_FACTOR` 주석 한 곳에 모아 뒀다.
|
||||
"hourly_krw": _money(Decimal(str(wage)) / Decimal(8) * OPERATOR_ALLOWANCE_FACTOR),
|
||||
# 식 좌→우 순차 + 원 미만 절사(명세 7장 · STmate 18번 §2.1).
|
||||
"hourly_krw": _money(hourly_operator_wage(Decimal(str(wage)))),
|
||||
"formula": "일당 ÷ 8시간 × 16/12 × 25/20",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -23,8 +23,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from decimal import ROUND_FLOOR, Decimal
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Guards import (
|
||||
@@ -73,6 +74,30 @@ OPERATOR_HOURS_PER_DAY = 8
|
||||
#: ⚠ **기계 감가상각도 여기가 아니다** — 상각비는 손료(경비) 쪽이다(품셈 8-1-5 1호).
|
||||
OPERATOR_ALLOWANCE_FACTOR = (Decimal(16) / Decimal(12)) * (Decimal(25) / Decimal(20))
|
||||
|
||||
#: 조종원 **시간당 노임 식** — 위 8시간·계수를 **식 문자열 한 줄**로(STmate 설정 `RXNM_`).
|
||||
#: ⚠⚠ **왼쪽부터 차례로** 평가하고 **원 미만 절사**(명세 7장 · STmate 18번 §2.1).
|
||||
#: 계수로 미리 접으면 `267,360 × 0.20833…` = 55,699.99… → 절사 55,699 로 **1원 틀림**.
|
||||
#: 차례로 하면 33,420 → 534,720 → 44,560 → 1,114,000 → **55,700**(6건×3직종 18/18 일치).
|
||||
#: ⚠ 위 `OPERATOR_ALLOWANCE_FACTOR` 는 근거 문구·검사용으로만 남김 — 금액에 곱하지 말 것.
|
||||
OPERATOR_WAGE_FORMULA = "1/8*16/12*25/20"
|
||||
|
||||
|
||||
def hourly_operator_wage(
|
||||
daily_wage: Decimal, formula: str = OPERATOR_WAGE_FORMULA, *, digits: int | None = 0
|
||||
) -> Decimal:
|
||||
"""일 노임 → 시간당 노임. 식을 **좌→우 순차**로 풀고 `digits` 자리 아래 절사(`None` 이면 안 자름).
|
||||
|
||||
⚠ 자르는 자리는 **실무 설정**임 — 실무 여섯 건 중 다섯이 원 미만 절사(55,700),
|
||||
2025 울진 소광 한 건이 0.1원 미만 절사(57,077.2). 기본은 다수인 0 자리.
|
||||
"""
|
||||
value = Decimal(daily_wage)
|
||||
for op, number in re.findall(r"([*/]?)\s*(\d+(?:\.\d+)?)", formula):
|
||||
value = value / Decimal(number) if op == "/" else value * Decimal(number)
|
||||
if digits is None:
|
||||
return value
|
||||
return value.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_FLOOR)
|
||||
|
||||
|
||||
#: 화면·표가 그대로 띄우는 노티스 한 줄. 계수를 쓴 자리마다 같은 문구가 서야 한다.
|
||||
OPERATOR_ALLOWANCE_NOTICE = (
|
||||
"조종원 노임에 제수당·상여금·퇴직급여충당금 계수 1.667배(16/12 × 25/20)를 넣었습니다 — "
|
||||
@@ -227,8 +252,6 @@ def hourly_machine_cost(
|
||||
fuel_liters_per_hour: Decimal | None = None,
|
||||
fuel_price_per_liter: Decimal | None = None,
|
||||
operator_daily_wage: Decimal | None = None,
|
||||
operator_hours_per_day: int = OPERATOR_HOURS_PER_DAY,
|
||||
operator_allowance_factor: Decimal = OPERATOR_ALLOWANCE_FACTOR,
|
||||
efficiency_factor: Decimal | None = None,
|
||||
) -> HourlyMachineCost:
|
||||
"""시간당 사용료 한 시간분.
|
||||
@@ -257,15 +280,16 @@ def hourly_machine_cost(
|
||||
# TODO(미결 PLAN 9-6): `mach_operator_map` 0건 — 기종별 운전사 직종이 품셈 본문에만 있다.
|
||||
gaps.append("운전사 직종 매핑 미확보 — 노무비 성분 비어 있음")
|
||||
else:
|
||||
# 나눗수는 8시간 그대로 두고 **계수를 곱한다** — 나눗수를 줄이는 것과 다르다.
|
||||
labor = (operator_daily_wage / Decimal(operator_hours_per_day)) * operator_allowance_factor
|
||||
# ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다.
|
||||
# 나눗수는 8시간 그대로 두고 **계수를 곱한다** — 식 문자열을 좌→우로 풂(명세 7장).
|
||||
exact = hourly_operator_wage(operator_daily_wage, digits=None)
|
||||
# ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다(자르기 전 값으로 봄).
|
||||
check_operator_hours_basis(
|
||||
labor_per_hour=labor,
|
||||
labor_per_hour=exact,
|
||||
daily_wage=operator_daily_wage,
|
||||
hours_per_day=operator_hours_per_day,
|
||||
allowance_factor=operator_allowance_factor,
|
||||
hours_per_day=OPERATOR_HOURS_PER_DAY,
|
||||
allowance_factor=OPERATOR_ALLOWANCE_FACTOR,
|
||||
)
|
||||
labor = exact.to_integral_value(rounding=ROUND_FLOOR)
|
||||
|
||||
return HourlyMachineCost(
|
||||
machine=machine,
|
||||
|
||||
@@ -25,8 +25,7 @@ from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_MachineCost import (
|
||||
OPERATOR_ALLOWANCE_FACTOR,
|
||||
OPERATOR_HOURS_PER_DAY,
|
||||
hourly_operator_wage,
|
||||
load_machine_catalog,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
|
||||
@@ -150,10 +149,9 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
|
||||
),
|
||||
"operator_code": occupation,
|
||||
"operator_daily_wage": _money(wage),
|
||||
# 식 좌→우 순차 + 원 미만 절사(명세 7장) — 계수로 접으면 1원 틀림.
|
||||
"operator_krw_per_hour": _money(
|
||||
(wage / Decimal(OPERATOR_HOURS_PER_DAY)) * OPERATOR_ALLOWANCE_FACTOR
|
||||
if wage is not None
|
||||
else None
|
||||
hourly_operator_wage(wage) if wage is not None else None
|
||||
),
|
||||
# ③ 시간당 사용료 — 조립된 값(이 장의 결론)
|
||||
"material_krw": _money(money.material),
|
||||
|
||||
@@ -27,7 +27,8 @@ from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once
|
||||
from B09_Estimation.B09_Estimation_MachineCost import (
|
||||
OPERATOR_ALLOWANCE_FACTOR,
|
||||
OPERATOR_WAGE_FORMULA,
|
||||
hourly_operator_wage,
|
||||
load_machine_catalog,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity import (
|
||||
@@ -68,6 +69,8 @@ from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||
from B09_Estimation.B09_Estimation_Transport import parse_distance_km
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
#: 조종원 **시간당 노임** 제목 꼬리 — 일 노임 제목(`L…`)과 갈라 둠. 노무비목록표엔 안 실림(일당만).
|
||||
HOURLY_WAGE_SUFFIX = "#시간"
|
||||
_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
|
||||
_ZERO = Decimal(0)
|
||||
#: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다.
|
||||
@@ -385,22 +388,26 @@ def _add_machine_layers(
|
||||
|
||||
wage_code = record.operator_occupation_code
|
||||
if wage_code and wage_code in wages and record.operator_person_days is not None:
|
||||
# ㉣ 나눗수는 8시간 — `PriceDetail` 수량이 「1시간분 인」이 된다.
|
||||
# 여기에 **제수당·상여금·퇴직급여충당금 계수**(1.667배)를 곱한다. 공표 노임이
|
||||
# 기본급여액뿐이라 별도 계상해야 하는 몫이다(`MachineCost` 상수 주석에 근거).
|
||||
per_hour_person = (record.operator_person_days / Decimal(8)) * OPERATOR_ALLOWANCE_FACTOR
|
||||
if wage_code not in book.titles:
|
||||
# ㉣ 나눗수는 8시간 + **제수당·상여금·퇴직급여충당금 계수**(16/12×25/20) — 공표 노임이
|
||||
# 기본급여액뿐이라 별도 계상하는 몫(`MachineCost` 상수 주석에 근거).
|
||||
# ⚠⚠ 계수를 **수량에 접지 않음** — `일당 × 0.20833…` 은 55,699.99… 로 1원 틀림.
|
||||
# 시간당 노임을 식 좌→우 + 원 미만 절사로 **따로 세운 제목**(`#시간`)을 1시간분 인수로 부름
|
||||
# (STmate 중기사용료 호표 「운전원 1 × 55,700」 모양, 명세 7장).
|
||||
per_hour_person = record.operator_person_days
|
||||
hourly_wage_code = f"{wage_code}{HOURLY_WAGE_SUFFIX}"
|
||||
if hourly_wage_code not in book.titles:
|
||||
book.add_title(
|
||||
PriceTitle(
|
||||
code=wage_code,
|
||||
code=hourly_wage_code,
|
||||
kind=PriceKind.LABOR,
|
||||
name="조종원",
|
||||
unit="인",
|
||||
slots=_slots(wages[wage_code]),
|
||||
name="조종원(시간당)",
|
||||
unit="hr",
|
||||
slots=_slots(hourly_operator_wage(wages[wage_code])),
|
||||
# ⚠ 조종원도 노임이다 — 같은 플래그가 붙어야 한다.
|
||||
reliability=load_labor_reliability().get(wage_code, ""),
|
||||
)
|
||||
)
|
||||
wage_code = hourly_wage_code
|
||||
# 조합 층에도 조종원을 같이 단다 — 본체를 모는 사람은 하나뿐이다.
|
||||
combined_code = f"{hourly_code}#조합"
|
||||
if combined_code in book.titles:
|
||||
@@ -409,7 +416,7 @@ def _add_machine_layers(
|
||||
combined_code,
|
||||
wage_code,
|
||||
per_hour_person,
|
||||
note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)",
|
||||
note=f"조종원 (일당 × {OPERATOR_WAGE_FORMULA} 좌→우 · 원 미만 절사)",
|
||||
)
|
||||
)
|
||||
book.add_detail(
|
||||
@@ -417,7 +424,7 @@ def _add_machine_layers(
|
||||
hourly_code,
|
||||
wage_code,
|
||||
per_hour_person,
|
||||
note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)",
|
||||
note=f"조종원 (일당 × {OPERATOR_WAGE_FORMULA} 좌→우 · 원 미만 절사)",
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""STmate 골든셋 재현 — 실무 내역 원본(평문 XLSX)을 **우리 엔진**으로 되풀어 대조(PLAN 6장·명세 8장).
|
||||
|
||||
기준점 — `resources/knowledge/original/실무문서/` 의 실무 6건(STmate 출력 XLSX).
|
||||
브레인 완료 판정 자리 — 6장 사슬 단계를 하나 세울 때마다 여기 한 벌씩 더하고 **단계마다 돌림**.
|
||||
|
||||
② 노임 시간당 환산 `환율및기초자료` 시트 — 일당 × 식 → 시간당 ✅ 이 판
|
||||
④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 (다음 단계)
|
||||
③ 단가산출 Q 식 `단가산출서` 시트 — 시간당 단가 ÷ Q (층 차례 뒤)
|
||||
① 일위대가·내역 절사 일위대가·내역 줄 — 성분별 절사 (마지막)
|
||||
|
||||
⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님).
|
||||
⚠ 값을 여기서 짓지 않음 — 원본 칸을 읽어 **엔진 함수의 입력**으로만 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import warnings
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation.B09_Estimation_MachineCost import hourly_operator_wage
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]:
|
||||
"""실무 XLSX 마다 쓰는 시트만 값으로 읽어 둠(한 번). `(상대 경로, {시트: 줄들})`."""
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
wanted = ("환율및기초자료",)
|
||||
found = []
|
||||
for path in sorted(PRACTICE.rglob("*.xlsx")):
|
||||
if path.name.startswith("~$"):
|
||||
continue
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
try:
|
||||
book = openpyxl.load_workbook(path, data_only=True, read_only=True)
|
||||
except Exception: # 깨진 사본 — 기준점이 아님
|
||||
continue
|
||||
sheets = {
|
||||
name: list(book[name].iter_rows(max_col=12, values_only=True))
|
||||
for name in wanted
|
||||
if name in book.sheetnames
|
||||
}
|
||||
book.close()
|
||||
if sheets:
|
||||
found.append((str(path.relative_to(PRACTICE)), sheets))
|
||||
return tuple(found)
|
||||
|
||||
|
||||
def _decimal_places(value: Decimal) -> int:
|
||||
return max(0, -value.normalize().as_tuple().exponent)
|
||||
|
||||
|
||||
def _wage_rows() -> list[tuple[str, str, Decimal, str, Decimal]]:
|
||||
rows = []
|
||||
for name, sheets in _workbooks():
|
||||
for row in sheets.get("환율및기초자료", []):
|
||||
if len(row) < 7 or not isinstance(row[3], (int, float)) or not isinstance(row[4], str):
|
||||
continue
|
||||
if row[2] and isinstance(row[6], (int, float)):
|
||||
formula = row[4].replace("*", "", 1).replace("=", "").strip()
|
||||
rows.append(
|
||||
(name, str(row[2]), Decimal(str(row[3])), formula, Decimal(str(row[6])))
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def test_노임_시간당_환산_실무_원본_전수_재현() -> None:
|
||||
"""② 일당 × `1/8*16/12*25/20` 좌→우 순차 + 절사 — 실무 원본의 시간당 칸과 **전부** 같음.
|
||||
|
||||
절사 자리는 원본이 보인 대로(다섯 건 원 · 2025 울진 소광 0.1원) — 설정값이라 칸에서 읽음.
|
||||
"""
|
||||
rows = _wage_rows()
|
||||
if not rows:
|
||||
pytest.skip("실무 원본 XLSX 가 없음")
|
||||
assert len(rows) >= 18 # 6건 × 운전사 3직종
|
||||
misses = [
|
||||
(name, job, daily, expected, got)
|
||||
for name, job, daily, formula, expected in rows
|
||||
if (got := hourly_operator_wage(daily, formula, digits=_decimal_places(expected)))
|
||||
!= expected
|
||||
]
|
||||
assert not misses, misses
|
||||
@@ -0,0 +1,69 @@
|
||||
"""조종원 시간당 노임 — 식 좌→우 순차 + 원 미만 절사 (2026-09-13, PLAN 6장 · 명세 7장).
|
||||
|
||||
기준점 — STmate 분석 18번 §2.1: 6건 × 3직종 18/18 이 좌→우일 때만 일치(계수 선계산 14/18).
|
||||
건설기계운전사 267,360 → 55,700 · 화물차운전사 226,709 → 47,231 ·
|
||||
일반기계운전사 161,142 → 33,571
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation.B09_Estimation_MachineCost import (
|
||||
OPERATOR_ALLOWANCE_FACTOR,
|
||||
OPERATOR_WAGE_FORMULA,
|
||||
hourly_operator_wage,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import HOURLY_WAGE_SUFFIX, build_unit_prices
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("daily", "hourly"),
|
||||
[(267360, 55700), (226709, 47231), (161142, 33571)],
|
||||
)
|
||||
def test_좌에서_우로_차례로_풀고_원_미만_절사(daily: int, hourly: int) -> None:
|
||||
assert OPERATOR_WAGE_FORMULA == "1/8*16/12*25/20"
|
||||
assert hourly_operator_wage(Decimal(daily)) == Decimal(hourly)
|
||||
|
||||
|
||||
def test_계수를_미리_접으면_1원_틀린다() -> None:
|
||||
"""회귀 막이 — 접은 계수 곱은 55,699.99… 라 절사하면 55,699(STmate 55,700)."""
|
||||
folded = (Decimal(267360) / 8 * OPERATOR_ALLOWANCE_FACTOR).to_integral_value(
|
||||
rounding="ROUND_FLOOR"
|
||||
)
|
||||
assert folded == Decimal(55699) # 종전 코드 — 계수 1.666…6 이 끝자리에서 잘려 55,699.99…
|
||||
assert hourly_operator_wage(Decimal(267360), digits=None) == Decimal(55700)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _build():
|
||||
return build_unit_prices()
|
||||
|
||||
|
||||
def test_중기_호표의_운전원은_시간당_노임_제목을_1시간분_부른다() -> None:
|
||||
book = _build().book
|
||||
hourly = [code for code in book.titles if code.endswith(HOURLY_WAGE_SUFFIX)]
|
||||
assert hourly, "조종원 시간당 제목이 한 벌도 안 섬"
|
||||
for code in hourly:
|
||||
daily = (
|
||||
book.titles[code.removesuffix(HOURLY_WAGE_SUFFIX)].adopted_price()
|
||||
if code.removesuffix(HOURLY_WAGE_SUFFIX) in book.titles
|
||||
else None
|
||||
)
|
||||
price = book.titles[code].adopted_price()
|
||||
assert price == price.to_integral_value() # 원 미만 절사
|
||||
if daily is not None:
|
||||
assert price == hourly_operator_wage(daily)
|
||||
# X 층 운전원 줄은 계수 접은 수량이 아니라 시간당 제목 × 인수.
|
||||
operator_rows = [
|
||||
detail
|
||||
for details in book.details.values()
|
||||
for detail in details
|
||||
if detail.ref_code.endswith(HOURLY_WAGE_SUFFIX)
|
||||
]
|
||||
assert operator_rows and all(
|
||||
d.quantity == d.quantity.to_integral_value() for d in operator_rows
|
||||
)
|
||||
@@ -46,7 +46,8 @@ def test_암절취는_암파쇄와_집토의_합으로_선다():
|
||||
assert all(d.quantity == Decimal(1) / Decimal("5.0") for d in leaf)
|
||||
whole = book.resolve("B-FP-09-04#연암").total
|
||||
parts = book.resolve("B-FP-09-04-01#연암").total + book.resolve("B-FP-09-04-02").total
|
||||
assert whole == parts > 0
|
||||
# 28자리 끝의 반올림 차례만 다를 수 있음 — 금액 자리(원)에서 같으면 같은 합.
|
||||
assert parts > 0 and abs(whole - parts) < Decimal("1e-15")
|
||||
assert "B-FP-09-04#평균" in book.titles # [주]① 평균
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user