feat: 생산완료→재고 자동입고 + 목표창고 API + 작업상세 창고선택 UI

- popProductionController: 작업지시 전체완료 시 마지막 공정의 목표창고로 inventory_stock UPSERT
- popProductionRoutes: 창고 목록/위치 조회, 마지막 공정 확인, 목표창고 업데이트 4개 API 추가
- PopWorkDetailComponent: 마지막 공정일 때 창고/위치 선택 UI 표시 (토글 지원)
This commit is contained in:
SeongHyun Kim 2026-03-30 19:51:21 +09:00
parent 768219046b
commit ee8c1eb0df
3 changed files with 504 additions and 7 deletions

View File

@ -985,7 +985,7 @@ const checkAndCompleteWorkInstruction = async (
const completedQty = totalGoodResult.rows[0].total_good;
await pool.query(
const updateResult = await pool.query(
`UPDATE work_instruction
SET status = 'completed',
progress_status = 'completed',
@ -993,13 +993,69 @@ const checkAndCompleteWorkInstruction = async (
writer = $4,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
AND status != 'completed'`,
AND status != 'completed'
RETURNING id, item_id`,
[woId, companyCode, String(completedQty), userId]
);
logger.info("[pop/production] 작업지시 전체 완료", {
woId, completedQty, companyCode,
});
// 생산완료→재고 입고: 마지막 공정의 target_warehouse_id가 설정된 경우 inventory_stock UPSERT
if (updateResult.rowCount > 0 && completedQty > 0) {
try {
const itemId = updateResult.rows[0].item_id;
// item_info에서 item_number 조회
const itemResult = await pool.query(
`SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`,
[itemId, companyCode]
);
if (itemResult.rowCount === 0) {
logger.warn("[pop/production] 재고입고 건너뜀: item_info 없음", { itemId, companyCode });
return;
}
const itemCode = itemResult.rows[0].item_number;
// 마지막 공정의 창고 설정 조회 (마스터 행에서)
const warehouseResult = await pool.query(
`SELECT target_warehouse_id, target_location_code
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NULL
LIMIT 1`,
[woId, maxSeq, companyCode]
);
if (warehouseResult.rowCount === 0 || !warehouseResult.rows[0].target_warehouse_id) {
logger.info("[pop/production] 재고입고 건너뜀: 목표창고 미설정", { woId });
return;
}
const warehouseCode = warehouseResult.rows[0].target_warehouse_id;
const locationCode = warehouseResult.rows[0].target_location_code || warehouseCode;
// inventory_stock UPSERT
await pool.query(
`INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6)
ON CONFLICT (company_code, item_code, warehouse_code, location_code)
DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text,
last_in_date = NOW(),
updated_date = NOW(),
writer = $6`,
[companyCode, itemCode, warehouseCode, locationCode, String(completedQty), userId]
);
logger.info("[pop/production] 생산완료→재고 입고 완료", {
woId, itemCode, warehouseCode, locationCode, qty: completedQty, companyCode,
});
} catch (inventoryError: any) {
// 재고 입고 실패해도 공정 완료는 유지 (재고는 보조 기능)
logger.error("[pop/production] 재고입고 오류 (공정 완료는 유지):", inventoryError);
}
}
};
/**
@ -1607,3 +1663,199 @@ export const cancelAccept = async (
});
}
};
/**
* (POP )
*/
export const getWarehouses = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const result = await pool.query(
`SELECT id, warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1 AND COALESCE(status, '') != '삭제'
ORDER BY warehouse_name`,
[companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("[pop/production] 창고 목록 조회 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* ()
* warehouseId는 warehouse_info.id warehouse_code를 warehouse_location과
*/
export const getWarehouseLocations = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { warehouseId } = req.params;
if (!warehouseId) {
return res.status(400).json({ success: false, message: "warehouseId는 필수입니다." });
}
// warehouse_info.id → warehouse_code 변환
const whInfo = await pool.query(
`SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`,
[warehouseId, companyCode]
);
if (whInfo.rowCount === 0) {
return res.json({ success: true, data: [] });
}
const warehouseCode = whInfo.rows[0].warehouse_code;
const result = await pool.query(
`SELECT id, location_code, location_name
FROM warehouse_location
WHERE warehouse_code = $1 AND company_code = $2
ORDER BY location_name`,
[warehouseCode, companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("[pop/production] 창고 위치 조회 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
*
* wo_id에서 seq_no보다 ( )
*/
export const isLastProcess = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { processId } = req.params;
if (!processId) {
return res.json({ success: true, data: { isLast: false } });
}
// 현재 공정의 wo_id와 seq_no 조회 (분할 행이면 parent의 seq_no 기준)
const process = await pool.query(
`SELECT wo_id, seq_no, parent_process_id
FROM work_order_process
WHERE id = $1 AND company_code = $2`,
[processId, companyCode]
);
if (process.rowCount === 0) {
return res.json({ success: true, data: { isLast: false } });
}
const { wo_id, seq_no, parent_process_id } = process.rows[0];
// 분할 행이면 마스터의 seq_no 기준으로 판단
let effectiveSeqNo = seq_no;
if (parent_process_id) {
const master = await pool.query(
`SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`,
[parent_process_id, companyCode]
);
if (master.rowCount > 0) {
effectiveSeqNo = master.rows[0].seq_no;
}
}
const next = await pool.query(
`SELECT id FROM work_order_process
WHERE wo_id = $1 AND company_code = $2
AND CAST(seq_no AS int) > CAST($3 AS int)
AND parent_process_id IS NULL
LIMIT 1`,
[wo_id, companyCode, effectiveSeqNo]
);
// 현재 공정의 기존 창고 설정도 반환 (기본값 세팅용)
const warehouseInfo = await pool.query(
`SELECT target_warehouse_id, target_location_code
FROM work_order_process
WHERE id = $1 AND company_code = $2`,
[processId, companyCode]
);
return res.json({
success: true,
data: {
isLast: next.rowCount === 0,
woId: wo_id,
seqNo: effectiveSeqNo,
targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null,
targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null,
},
});
} catch (error: any) {
logger.error("[pop/production] 마지막 공정 확인 오류:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* /
* .
* checkAndCompleteWorkInstruction이 .
*/
export const updateTargetWarehouse = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id, target_warehouse_id, target_location_code } = req.body;
if (!work_order_process_id || !target_warehouse_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id와 target_warehouse_id는 필수입니다.",
});
}
// 분할 행이면 마스터 행도 함께 업데이트
const procInfo = await pool.query(
`SELECT parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
const idsToUpdate = [work_order_process_id];
if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) {
idsToUpdate.push(procInfo.rows[0].parent_process_id);
}
for (const id of idsToUpdate) {
await pool.query(
`UPDATE work_order_process
SET target_warehouse_id = $3,
target_location_code = $4,
writer = $5,
updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[id, companyCode, target_warehouse_id, target_location_code || null, userId]
);
}
logger.info("[pop/production] 목표 창고 업데이트", {
companyCode, userId, work_order_process_id,
target_warehouse_id, target_location_code,
updatedIds: idsToUpdate,
});
return res.json({ success: true, data: { target_warehouse_id, target_location_code } });
} catch (error: any) {
logger.error("[pop/production] 목표 창고 업데이트 오류:", error);
return res.status(500).json({ success: false, message: error.message });
}
};

View File

@ -11,6 +11,10 @@ import {
getAvailableQty,
acceptProcess,
cancelAccept,
getWarehouses,
getWarehouseLocations,
isLastProcess,
updateTargetWarehouse,
} from "../controllers/popProductionController";
const router = Router();
@ -27,5 +31,9 @@ router.get("/result-history", getResultHistory);
router.get("/available-qty", getAvailableQty);
router.post("/accept-process", acceptProcess);
router.post("/cancel-accept", cancelAccept);
router.get("/warehouses", getWarehouses);
router.get("/warehouse-locations/:warehouseId", getWarehouseLocations);
router.get("/is-last-process/:processId", isLastProcess);
router.post("/update-target-warehouse", updateTargetWarehouse);
export default router;

View File

@ -17,6 +17,12 @@ import { dataApi } from "@/lib/api/data";
import { apiClient } from "@/lib/api/client";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useAuth } from "@/hooks/useAuth";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import type { PopWorkDetailConfig, ResultSectionConfig, ResultSectionType, PlcDataConfig } from "../types";
import type { TimelineProcessStep } from "../types";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
@ -344,6 +350,16 @@ export function PopWorkDetailComponent({
// 불량 유형 목록 (부모에서 1회 로드, ResultPanel에 전달)
const [cachedDefectTypes, setCachedDefectTypes] = useState<DefectTypeOption[]>([]);
// 창고 선택 모달 상태
const [showWarehouseModal, setShowWarehouseModal] = useState(false);
const [warehouses, setWarehouses] = useState<{ id: string; warehouse_code: string; warehouse_name: string; warehouse_type: string }[]>([]);
const [locations, setLocations] = useState<{ id: string; location_code: string; location_name: string }[]>([]);
const [selectedWarehouse, setSelectedWarehouse] = useState("");
const [selectedLocation, setSelectedLocation] = useState("");
const [useDefaultWarehouse, setUseDefaultWarehouse] = useState(false);
const [warehouseSaving, setWarehouseSaving] = useState(false);
const [pendingCompletionData, setPendingCompletionData] = useState<Partial<ProcessTimerData> | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
// ========================================
@ -397,6 +413,128 @@ export function PopWorkDetailComponent({
loadDefectTypes();
}, []);
// ========================================
// 창고 선택 (마지막 공정 완료 시)
// ========================================
const loadWarehouses = useCallback(async () => {
try {
const res = await apiClient.get("/pop/production/warehouses");
if (res.data?.success) {
setWarehouses(res.data.data || []);
}
} catch { /* 실패 시 빈 배열 유지 */ }
}, []);
const loadLocations = useCallback(async (warehouseId: string) => {
if (!warehouseId) { setLocations([]); return; }
try {
const res = await apiClient.get(`/pop/production/warehouse-locations/${warehouseId}`);
if (res.data?.success) {
setLocations(res.data.data || []);
}
} catch { setLocations([]); }
}, []);
// 창고 선택 변경 시 위치 목록 갱신
useEffect(() => {
if (selectedWarehouse) {
loadLocations(selectedWarehouse);
setSelectedLocation("");
} else {
setLocations([]);
}
}, [selectedWarehouse, loadLocations]);
/**
*
* ResultPanel의 onSaved handleTimerAction("complete")
*/
const checkLastProcessAndShowWarehouse = useCallback(async (completionData: Partial<ProcessTimerData>) => {
if (!workOrderProcessId) return false;
try {
const res = await apiClient.get(`/pop/production/is-last-process/${workOrderProcessId}`);
if (res.data?.success && res.data.data?.isLast) {
const { targetWarehouseId, targetLocationCode } = res.data.data;
// 이미 창고가 설정되어 있으면 모달 건너뛰기
if (targetWarehouseId) {
return false;
}
// 창고 목록 로드 후 모달 표시
await loadWarehouses();
setPendingCompletionData(completionData);
setShowWarehouseModal(true);
return true;
}
} catch {
// 확인 실패 시 모달 없이 진행
}
return false;
}, [workOrderProcessId, loadWarehouses]);
const handleWarehouseConfirm = useCallback(async () => {
if (!workOrderProcessId || !selectedWarehouse) {
toast.error("창고를 선택해주세요.");
return;
}
setWarehouseSaving(true);
try {
// warehouse_code를 찾기 (API에서 warehouse_code로 저장하므로)
const wh = warehouses.find((w) => w.id === selectedWarehouse);
const warehouseCode = wh?.warehouse_code || selectedWarehouse;
await apiClient.post("/pop/production/update-target-warehouse", {
work_order_process_id: workOrderProcessId,
target_warehouse_id: warehouseCode,
target_location_code: selectedLocation || null,
});
// 기본 창고 설정 (로컬 스토리지)
if (useDefaultWarehouse && wh) {
localStorage.setItem("pop_default_warehouse", JSON.stringify({
id: wh.id,
warehouse_code: wh.warehouse_code,
warehouse_name: wh.warehouse_name,
location_code: selectedLocation || null,
}));
}
toast.success("입고 창고가 설정되었습니다.");
setShowWarehouseModal(false);
// 보류 중이던 완료 데이터 반영
if (pendingCompletionData) {
setProcessData((prev) => prev ? { ...prev, ...pendingCompletionData } : prev);
publish("process_completed", { workOrderProcessId, ...pendingCompletionData });
setPendingCompletionData(null);
}
} catch {
toast.error("창고 설정에 실패했습니다.");
} finally {
setWarehouseSaving(false);
}
}, [workOrderProcessId, selectedWarehouse, selectedLocation, useDefaultWarehouse, warehouses, pendingCompletionData, publish]);
// 모달 열릴 때 기본 창고 확인
useEffect(() => {
if (showWarehouseModal) {
const saved = localStorage.getItem("pop_default_warehouse");
if (saved) {
try {
const def = JSON.parse(saved);
if (def.warehouse_code) {
const found = warehouses.find((w) => w.warehouse_code === def.warehouse_code);
if (found) {
setSelectedWarehouse(found.id);
if (def.location_code) setSelectedLocation(def.location_code);
setUseDefaultWarehouse(true);
}
}
} catch { /* 파싱 실패 무시 */ }
}
}
}, [showWarehouseModal, warehouses]);
// ========================================
// 좌측 사이드바 - 작업항목 그룹핑
// ========================================
@ -768,17 +906,23 @@ export function PopWorkDetailComponent({
});
const proc = (res.data?.[0] ?? null) as ProcessTimerData | null;
if (proc) {
setProcessData(proc);
if (action === "complete") {
toast.success("공정이 완료되었습니다.");
publish("process_completed", { workOrderProcessId, goodQty, defectQty });
// 마지막 공정이면 창고 선택 모달 표시
const shown = await checkLastProcessAndShowWarehouse(proc);
if (!shown) {
setProcessData(proc);
toast.success("공정이 완료되었습니다.");
publish("process_completed", { workOrderProcessId, goodQty, defectQty });
}
} else {
setProcessData(proc);
}
}
} catch {
toast.error("타이머 제어에 실패했습니다.");
}
},
[workOrderProcessId, goodQty, defectQty, publish]
[workOrderProcessId, goodQty, defectQty, publish, checkLastProcessAndShowWarehouse]
);
// ========================================
@ -1071,7 +1215,12 @@ export function PopWorkDetailComponent({
sections={cfg.resultSections ?? []}
isProcessCompleted={isProcessCompleted}
defectTypes={cachedDefectTypes}
onSaved={(updated) => {
onSaved={async (updated) => {
// 자동 완료 시 마지막 공정이면 창고 선택 모달 표시
if (updated?.status === "completed") {
const shown = await checkLastProcessAndShowWarehouse(updated);
if (shown) return; // 모달이 뜨면 여기서 중단, 확인 후 반영
}
setProcessData((prev) => prev ? { ...prev, ...updated } : prev);
publish("process_completed", { workOrderProcessId, status: updated?.status });
}}
@ -1324,6 +1473,94 @@ export function PopWorkDetailComponent({
</Badge>
</div>
)}
{/* 창고 선택 모달 (마지막 공정 완료 시) */}
<Dialog open={showWarehouseModal} onOpenChange={(open) => {
if (!open && pendingCompletionData) {
// 모달 닫기 시 보류 중이던 완료 데이터 반영 (창고 미선택)
setProcessData((prev) => prev ? { ...prev, ...pendingCompletionData } : prev);
publish("process_completed", { workOrderProcessId, ...pendingCompletionData });
setPendingCompletionData(null);
}
setShowWarehouseModal(open);
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5 text-blue-600" />
</DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"></label>
<Select value={selectedWarehouse} onValueChange={setSelectedWarehouse}>
<SelectTrigger className="h-12">
<SelectValue placeholder="창고를 선택하세요" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.warehouse_name} ({w.warehouse_code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{locations.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"> ()</label>
<Select value={selectedLocation} onValueChange={setSelectedLocation}>
<SelectTrigger className="h-12">
<SelectValue placeholder="위치를 선택하세요" />
</SelectTrigger>
<SelectContent>
{locations.map((l) => (
<SelectItem key={l.location_code} value={l.location_code}>
{l.location_name} ({l.location_code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<label className="flex items-center gap-2 pt-2">
<Checkbox
checked={useDefaultWarehouse}
onCheckedChange={(v) => setUseDefaultWarehouse(v === true)}
/>
<span className="text-sm text-gray-600"> </span>
</label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowWarehouseModal(false);
if (pendingCompletionData) {
setProcessData((prev) => prev ? { ...prev, ...pendingCompletionData } : prev);
publish("process_completed", { workOrderProcessId, ...pendingCompletionData });
setPendingCompletionData(null);
}
}}
>
</Button>
<Button
onClick={handleWarehouseConfirm}
disabled={!selectedWarehouse || warehouseSaving}
>
{warehouseSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}