feat(malaysia): 재고현황 조사수량·차이 표기, 확정 관리자 제한, 슈퍼관리자 조사삭제
- 재고현황 헤더 "전산 재고 현황", Last Stocktake 옆 조사수량+차이(±) 표기 (차이 = 마지막 재고조사 낱개 total − 전산재고) - 재고조사 확정(전산 반영)은 관리자(is_admin)만 가능, 비관리자 안내 - 슈퍼관리자만 재고조사 완전 삭제(목록/상세 버튼 + /delete 라우트) - "◀◀ 조사 목록" 버튼 검정 바탕(primary) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -360,6 +360,28 @@ class MalaysiaStockStore:
|
||||
out.sort(key=lambda x: x["item_code"])
|
||||
return out
|
||||
|
||||
def latest_stocktake(self, *, warehouse_code: str) -> dict[str, Any] | None:
|
||||
"""가장 최근(취소 제외) 재고조사 헤더. 없으면 None."""
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM daily_stocktake "
|
||||
"WHERE warehouse_code = %s AND status <> 'cancelled' "
|
||||
"ORDER BY stocktake_date DESC, id DESC LIMIT 1",
|
||||
(warehouse_code,),
|
||||
).fetchone()
|
||||
return self._serialize(row) if row else None
|
||||
|
||||
def delete_stocktake(self, *, stocktake_id: int) -> None:
|
||||
"""재고조사 완전 삭제(헤더+라인 CASCADE). 슈퍼관리자 전용 동작.
|
||||
|
||||
주의: finalize 시 생성된 STOCKTAKE movement 는 별도이므로 같이 지우지 않는다
|
||||
(재고 이력 보존). 헤더/라인만 제거.
|
||||
"""
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute("DELETE FROM daily_stocktake WHERE id = %s", (stocktake_id,))
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(stocktake_id)
|
||||
|
||||
def latest_set_quantities(self, *, warehouse_code: str) -> dict[str, Any]:
|
||||
"""가장 최근(취소 제외) 재고조사에 입력된 콤보(MY-) 수량.
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ def _base_ctx(request: Request, user: dict[str, Any], st: Any, *, wh: str) -> di
|
||||
return {
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_super": bool(user.get("is_super_admin")),
|
||||
"nav_items": build_erp_nav(user, active="malaysia"),
|
||||
"warehouses": st.list_warehouses(),
|
||||
"selected_wh": wh,
|
||||
@@ -143,12 +144,26 @@ async def index(request: Request) -> HTMLResponse:
|
||||
st, user = guard
|
||||
wh = _selected_wh(request, st)
|
||||
ctx = _base_ctx(request, user, st, wh=wh)
|
||||
rows = st.stock_status(warehouse_code=wh)
|
||||
# 마지막 재고조사(취소 제외) 의 낱개 기준 total 과 전산재고 차이
|
||||
latest = st.latest_stocktake(warehouse_code=wh)
|
||||
last_date = latest["stocktake_date"] if latest else None
|
||||
last_map: dict[str, int] = {}
|
||||
if latest:
|
||||
comp = st.compute_result(stocktake_id=latest["id"], bom_map=_bom_map(request))
|
||||
last_map = {r["item_code"]: r["total_qty"] for r in comp["rows"]}
|
||||
for r in rows:
|
||||
code = r["item_code"]
|
||||
lq = last_map.get(code)
|
||||
r["last_stocktake_date"] = last_date
|
||||
r["last_qty"] = lq
|
||||
r["diff"] = (lq - r["current_qty"]) if lq is not None else None
|
||||
set_stock = st.latest_set_quantities(warehouse_code=wh)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "말레이시아 재고관리",
|
||||
"page_subtitle": f"{wh} · 재고 현황",
|
||||
"rows": st.stock_status(warehouse_code=wh),
|
||||
"rows": rows,
|
||||
"set_rows": set_stock["rows"],
|
||||
"set_stocktake_date": set_stock["stocktake_date"],
|
||||
}
|
||||
@@ -410,6 +425,10 @@ async def stocktake_line_delete(
|
||||
async def stocktake_finalize(
|
||||
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="전산 재고 반영은 관리자만 가능합니다.")
|
||||
st = _store(request)
|
||||
if st is None:
|
||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||
@@ -436,6 +455,24 @@ async def stocktake_cancel(
|
||||
return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/stocktakes/{stocktake_id:int}/delete")
|
||||
async def stocktake_delete(
|
||||
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
|
||||
) -> RedirectResponse:
|
||||
"""재고조사 완전 삭제 — 슈퍼관리자 전용."""
|
||||
if not user.get("is_super_admin"):
|
||||
raise HTTPException(status_code=403, detail="재고조사 삭제는 슈퍼관리자만 가능합니다.")
|
||||
st = _store(request)
|
||||
if st is None:
|
||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||
wh = _selected_wh(request, st)
|
||||
try:
|
||||
st.delete_stocktake(stocktake_id=stocktake_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
|
||||
return RedirectResponse(url=f"/malaysia/stocktakes?wh={wh}", status_code=303)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# JSON API
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -19,13 +19,19 @@
|
||||
<!-- 좌: 낱개로 분리된 전체 재고 -->
|
||||
<div class="erp-card">
|
||||
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h2>재고 현황 — 낱개 기준 ({{ rows|length }})</h2>
|
||||
<h2>전산 재고 현황 — 낱개 기준 ({{ rows|length }})</h2>
|
||||
<span class="erp-muted">현재고 = 입고 − 출고 + 조정 + 재고조사반영 (콤보는 낱개로 분해 반영됨)</span>
|
||||
</div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr><th class="ccol">Item Code</th><th>Product Name</th><th style="text-align:right">Current Qty</th><th>Last Stocktake</th></tr>
|
||||
<tr>
|
||||
<th class="ccol">Item Code</th><th>Product Name</th>
|
||||
<th style="text-align:right">전산재고</th>
|
||||
<th>Last Stocktake</th>
|
||||
<th style="text-align:right">조사수량</th>
|
||||
<th style="text-align:right">차이</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
@@ -34,10 +40,14 @@
|
||||
<td class="nm">{{ r.item_name }}</td>
|
||||
<td style="text-align:right;font-weight:600;">{{ r.current_qty }}</td>
|
||||
<td>{{ r.last_stocktake_date or '—' }}</td>
|
||||
<td style="text-align:right">{{ r.last_qty if r.last_qty is not none else '—' }}</td>
|
||||
<td style="text-align:right;font-weight:600;{% if r.diff %}color:#b91c1c;{% endif %}">
|
||||
{% if r.diff is not none %}{{ '%+d'|format(r.diff) }}{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not rows %}
|
||||
<tr><td colspan="4" class="erp-muted">표시할 재고가 없습니다.</td></tr>
|
||||
<tr><td colspan="6" class="erp-muted">표시할 재고가 없습니다.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{% block content %}
|
||||
<section class="mys mys-compact">
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-outline" href="/malaysia/stocktakes?wh={{ stocktake.warehouse_code }}">◀◀ 조사 목록</a>
|
||||
<a class="erp-btn erp-btn-primary" href="/malaysia/stocktakes?wh={{ stocktake.warehouse_code }}">◀◀ 조사 목록</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/lines/bulk">
|
||||
@@ -88,18 +88,28 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 확정/취소 -->
|
||||
{% if is_draft %}
|
||||
<div class="erp-page-actions" style="margin-top:12px;display:flex;gap:8px;">
|
||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/finalize"
|
||||
onsubmit="return confirm('확정하면 시스템 재고와의 차이가 STOCKTAKE 이동으로 기록되고, 이후 수정 불가합니다. 계속?');">
|
||||
<button type="submit" class="erp-btn erp-btn-primary" {% if missing_bom %}disabled title="BOM 누락 콤보 해결 필요"{% endif %}>확정 (재고 반영)</button>
|
||||
</form>
|
||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/cancel" onsubmit="return confirm('이 조사를 취소합니다. 계속?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">취소</button>
|
||||
<!-- 확정/취소/삭제 -->
|
||||
<div class="erp-page-actions" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
{% if is_draft %}
|
||||
{% if is_admin %}
|
||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/finalize"
|
||||
onsubmit="return confirm('확정하면 전산 재고와의 차이가 STOCKTAKE 이동으로 기록되고, 이후 수정 불가합니다. 계속?');">
|
||||
<button type="submit" class="erp-btn erp-btn-primary" {% if missing_bom %}disabled title="BOM 누락 콤보 해결 필요"{% endif %}>확정 (전산 반영)</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="erp-muted">전산 재고 반영(확정)은 관리자만 가능합니다.</span>
|
||||
{% endif %}
|
||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/cancel" onsubmit="return confirm('이 조사를 취소합니다. 계속?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">취소</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_super %}
|
||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/delete"
|
||||
onsubmit="return confirm('이 재고조사를 완전 삭제합니다(되돌릴 수 없음). 계속?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제 (슈퍼관리자)</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 최종 계산 결과 (낱개 기준) -->
|
||||
<div class="erp-card" style="margin-top:16px;">
|
||||
|
||||
@@ -39,7 +39,14 @@
|
||||
{% else %}<span class="erp-badge erp-badge-inverse">작성중</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ s.created_by or '—' }}</td>
|
||||
<td><a class="erp-btn erp-btn-outline" href="/malaysia/stocktakes/{{ s.id }}">열기</a></td>
|
||||
<td style="display:flex;gap:6px;">
|
||||
<a class="erp-btn erp-btn-outline" href="/malaysia/stocktakes/{{ s.id }}">열기</a>
|
||||
{% if is_super %}
|
||||
<form method="post" action="/malaysia/stocktakes/{{ s.id }}/delete" onsubmit="return confirm('재고조사 #{{ s.id }} 완전 삭제. 계속?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not stocktakes %}
|
||||
|
||||
Reference in New Issue
Block a user