feat(stock): 창고별 재고 현황 메뉴 (출고/정산 > 창고별 재고 현황)
Deploy momo-erp / deploy (push) Successful in 2m7s

매트릭스 뷰 — 행: 품목, 열: 창고 7개, 셀: 발주수량/여유분 2행.

- 발주수량 = 현재 보유 재고 (momo_stocks.qty)
- 여유분  = 현재고 - 기간 내 발주(REQUESTED/APPROVED/INVOICED/PAID)
           의 출고 예정 수량 (거래처 default 창고 기준)
- 기간 필터: dateFrom/dateTo (기본 이번 주 월 ~ 오늘) + '금주' 버튼
- 엑셀 다운로드 지원
- 여유분 음수면 rose 강조 (재고 부족 경고)

운영 DB menu_info 9000420 등록 (parent 9000400 출고/정산).
This commit is contained in:
chpark
2026-05-14 16:47:03 +09:00
parent 789909991a
commit 527cfddc1b
2 changed files with 261 additions and 0 deletions
@@ -0,0 +1,154 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { RefreshCcw, Warehouse, Download } from "lucide-react";
import { downloadXlsx } from "@/lib/xlsx-export";
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
interface ItemRow {
ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
STOCK: Record<string, number>; // wh_code → 현재고
AVAILABLE: Record<string, number>; // wh_code → 여유분(현재고 - 진행중)
TOTAL_STOCK: number;
TOTAL_PENDING: number;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
// 이번 주 월요일 → 오늘
function weekRange() {
const today = new Date();
const day = today.getDay(); // 0=일
const mondayOffset = day === 0 ? -6 : 1 - day;
const monday = new Date(today);
monday.setDate(today.getDate() + mondayOffset);
const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return [iso(monday), iso(today)];
}
export default function WhStockStatusPage() {
const [[dateFrom, dateTo], setRange] = useState(weekRange());
const [warehouses, setWarehouses] = useState<Wh[]>([]);
const [items, setItems] = useState<ItemRow[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/m/admin/wh-stock-status", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dateFrom, dateTo }),
});
const j = await res.json();
setWarehouses(j.WAREHOUSES ?? []);
setItems(j.ITEMS ?? []);
} finally { setLoading(false); }
}, [dateFrom, dateTo]);
useEffect(() => { load(); }, [load]);
const onExport = () => {
if (items.length === 0) return;
const cols = [
{ header: "품목", key: "ITEM_NAME" },
{ header: "분류", key: "KIND" },
...warehouses.map((w) => ({ header: w.WH_NAME, key: w.WH_CODE })),
];
type Row = Record<string, string | number>;
const data: Row[] = [];
for (const it of items) {
const stockRow: Row = { ITEM_NAME: it.ITEM_NAME, KIND: "발주수량(현재고)" };
const availRow: Row = { ITEM_NAME: it.ITEM_NAME, KIND: "여유분" };
for (const w of warehouses) {
stockRow[w.WH_CODE] = Number(it.STOCK[w.WH_CODE] ?? 0);
availRow[w.WH_CODE] = Number(it.AVAILABLE[w.WH_CODE] ?? 0);
}
data.push(stockRow, availRow);
}
downloadXlsx(`창고별재고_${dateFrom}_${dateTo}`, data, cols);
};
return (
<div className="space-y-4">
<div className="flex items-end justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<Warehouse size={20} className="text-emerald-700" />
</h1>
<p className="text-xs text-slate-500 mt-0.5">
(///) . <b></b> = , <b></b> = ( ).
</p>
</div>
<div className="flex items-center gap-2">
<input type="date" value={dateFrom} onChange={(e) => setRange([e.target.value, dateTo])}
className="h-9 px-3 rounded border border-slate-300 text-sm" />
<span className="text-slate-400">~</span>
<input type="date" value={dateTo} onChange={(e) => setRange([dateFrom, e.target.value])}
className="h-9 px-3 rounded border border-slate-300 text-sm" />
<button onClick={() => setRange(weekRange())}
className="h-9 px-3 rounded border border-slate-300 bg-white text-slate-700 text-xs font-semibold"></button>
<button onClick={load} disabled={loading}
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50">
<RefreshCcw size={14} />
</button>
<button onClick={onExport} disabled={items.length === 0}
className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800 disabled:opacity-40">
<Download size={14} />
</button>
</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left px-3 py-2 border-b border-slate-200 min-w-[160px]"></th>
<th className="text-center px-3 py-2 border-b border-slate-200 min-w-[100px]"></th>
{warehouses.map((w) => (
<th key={w.WH_CODE} className="text-right px-3 py-2 border-b border-slate-200 min-w-[88px]">
{w.WH_NAME}
</th>
))}
</tr>
</thead>
<tbody className="tabular-nums">
{items.length === 0 ? (
<tr><td colSpan={2 + warehouses.length} className="text-center py-12 text-slate-400">
{loading ? "조회 중..." : "데이터가 없습니다."}
</td></tr>
) : items.flatMap((it) => [
<tr key={`${it.ITEM_OBJID}-stock`} className="border-t border-slate-100 hover:bg-slate-50/60">
<td className="px-3 py-2 align-top font-semibold" rowSpan={2}>
{it.ITEM_NAME}
<div className="text-[10px] text-slate-400 font-mono">{it.ITEM_CODE}</div>
</td>
<td className="px-3 py-1.5 text-center text-[11px] text-slate-700 bg-slate-50/70"></td>
{warehouses.map((w) => {
const v = Number(it.STOCK[w.WH_CODE] ?? 0);
return (
<td key={w.WH_CODE} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-slate-800"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
<tr key={`${it.ITEM_OBJID}-avail`} className="border-b border-slate-100">
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-50/40 font-semibold"></td>
{warehouses.map((w) => {
const v = Number(it.AVAILABLE[w.WH_CODE] ?? 0);
const negative = v < 0;
return (
<td key={w.WH_CODE} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600 font-bold" : v === 0 ? "text-slate-300" : "text-emerald-700 font-semibold"}`}>
{v === 0 ? "-" : fmt(v)}
</td>
);
})}
</tr>,
])}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,107 @@
// 창고별 재고 현황 — 7개 창고 × 활성 품목 매트릭스
// 발주수량 = 보유 재고 (momo_stocks.qty)
// 여유분 = 보유 재고 - 입금 전 진행 중 발주의 출고예정 수량 (해당 창고 기준)
import { NextRequest, NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const body = await req.json().catch(() => ({}));
const dateFrom = (body.dateFrom as string) || ""; // YYYY-MM-DD
const dateTo = (body.dateTo as string) || "";
// 1) 창고 7개 (wh_code 순)
const warehouses = await queryRows<{ OBJID: string; WH_CODE: string; WH_NAME: string }>(
`SELECT objid AS "OBJID", wh_code AS "WH_CODE", wh_name AS "WH_NAME"
FROM momo_warehouses
WHERE COALESCE(is_del,'N') != 'Y'
ORDER BY wh_code`
);
// 2) 품목 + 창고별 stock + 기간 내 발주(예정/완료) 수량
// 발주수량 = 기간 내 status IN (REQUESTED/APPROVED/INVOICED/PAID) 의 ITEM 수량 합
// 여유분 = 현재고 - 발주수량 (음수면 부족)
const dateConds: string[] = ["COALESCE(O.is_del,'N') != 'Y'", "COALESCE(OI.kind, 'ITEM') = 'ITEM'",
"O.status IN ('REQUESTED','APPROVED','INVOICED','PAID')"];
const params: unknown[] = [];
if (dateFrom) { params.push(dateFrom); dateConds.push(`O.order_date >= $${params.length}::date`); }
if (dateTo) { params.push(dateTo); dateConds.push(`O.order_date <= $${params.length}::date`); }
const rows = await queryRows<{
ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
WH_OBJID: string; WH_CODE: string;
STOCK_QTY: string; PENDING_QTY: string;
}>(
`WITH stock AS (
SELECT item_objid, wh_objid, qty
FROM momo_stocks
),
pending AS (
SELECT OI.item_objid,
COALESCE(U.default_wh_objid::text, (SELECT objid FROM momo_warehouses WHERE wh_code='WH001')) AS wh_objid,
SUM(OI.qty) AS qty
FROM momo_order_items OI
JOIN momo_orders O ON O.objid = OI.order_objid
LEFT JOIN user_info U ON U.user_id = O.customer_objid
WHERE ${dateConds.join(" AND ")}
GROUP BY OI.item_objid, COALESCE(U.default_wh_objid::text, (SELECT objid FROM momo_warehouses WHERE wh_code='WH001'))
)
SELECT
I.objid AS "ITEM_OBJID",
I.item_code AS "ITEM_CODE",
I.item_name AS "ITEM_NAME",
W.objid AS "WH_OBJID",
W.wh_code AS "WH_CODE",
COALESCE(s.qty, 0) AS "STOCK_QTY",
COALESCE(p.qty, 0) AS "PENDING_QTY"
FROM momo_items I
CROSS JOIN momo_warehouses W
LEFT JOIN stock s ON s.item_objid = I.objid AND s.wh_objid = W.objid
LEFT JOIN pending p ON p.item_objid = I.objid AND p.wh_objid = W.objid
WHERE COALESCE(I.is_del,'N') != 'Y'
AND COALESCE(W.is_del,'N') != 'Y'
AND COALESCE(I.is_hidden,'N') != 'Y'
ORDER BY I.item_name, W.wh_code`,
params
);
// 품목별 매트릭스로 정리 — 클라가 쓰기 쉽게
type Matrix = {
ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
STOCK: Record<string, number>;
AVAILABLE: Record<string, number>;
TOTAL_STOCK: number;
TOTAL_PENDING: number;
};
const byItem = new Map<string, Matrix>();
for (const r of rows) {
const stockQty = Number(r.STOCK_QTY);
const pendingQty = Number(r.PENDING_QTY);
const avail = stockQty - pendingQty;
if (!byItem.has(r.ITEM_OBJID)) {
byItem.set(r.ITEM_OBJID, {
ITEM_OBJID: r.ITEM_OBJID, ITEM_CODE: r.ITEM_CODE, ITEM_NAME: r.ITEM_NAME,
STOCK: {}, AVAILABLE: {}, TOTAL_STOCK: 0, TOTAL_PENDING: 0,
});
}
const m = byItem.get(r.ITEM_OBJID)!;
m.STOCK[r.WH_CODE] = stockQty;
m.AVAILABLE[r.WH_CODE] = avail;
m.TOTAL_STOCK += stockQty;
m.TOTAL_PENDING += pendingQty;
}
// 재고가 한 창고라도 있거나 진행중 발주가 있는 품목만 노출
const items = Array.from(byItem.values()).filter(
(m) => m.TOTAL_STOCK > 0 || m.TOTAL_PENDING > 0
);
return NextResponse.json({
WAREHOUSES: warehouses,
ITEMS: items,
TOTAL_CNT: items.length,
});
}