Add POP Inventory Move Feature and Update Shared POP Components
- New POP inventory move page and API (popInventoryMoveController, popInventoryMove client) deployed across COMPANY_7/8/9/10/16/29/30 - Updates to shared POP components (PopShell, AcceptProcessModal, ProcessWork) and inout-manage/inventory pages - COMPANY_7 POP.md updated with new scope notes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -125,12 +125,9 @@ export async function getInOutHistory(
|
||||
LEFT JOIN warehouse_info wh
|
||||
ON wh.warehouse_code = ih.warehouse_code
|
||||
AND wh.company_code = ih.company_code
|
||||
-- 이동 row 는 출발(-)/도착(+) 양쪽 모두 노출 (2026-05-11 사용자 결정)
|
||||
WHERE ($1::text = '*' OR ih.company_code = $1)
|
||||
AND ih.transaction_type = ANY($2::text[])
|
||||
AND NOT (
|
||||
ih.transaction_type = '이동'
|
||||
AND COALESCE(CAST(NULLIF(ih.quantity::text, '') AS numeric), 0) < 0
|
||||
)
|
||||
)
|
||||
`;
|
||||
|
||||
|
||||
386
backend-node/src/controllers/popInventoryMoveController.ts
Normal file
386
backend-node/src/controllers/popInventoryMoveController.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* popInventoryMoveController — 신 POP 재고이동 전용 컨트롤러
|
||||
*
|
||||
* 사용처: frontend/app/(main)/COMPANY_X/pop/inventory/move/
|
||||
*
|
||||
* 동작:
|
||||
* - 출발 stock 차감 + 도착 stock UPSERT
|
||||
* - inventory_history 2건 INSERT (출발 -qty, 도착 +qty, transaction_type='이동')
|
||||
* - 동일창고 이동 가드: warehouse_location 등록 시에만 허용
|
||||
* - 음수 재고 차단
|
||||
* - cart_type='inventory-move' 임시저장
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const CART_TYPE = "inventory-move";
|
||||
|
||||
interface CommitItem {
|
||||
stock_id: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
interface CommitBody {
|
||||
items: CommitItem[];
|
||||
to_warehouse_code: string;
|
||||
to_location_code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/pop/inventory/move/stock-list
|
||||
* 재고이동 화면용 재고 목록 조회 (출발창고 + 키워드 필터)
|
||||
*
|
||||
* Query: warehouse_code (선택, 'all' or 창고코드), keyword (선택, 품목명/코드)
|
||||
*/
|
||||
export const getMoveStockList = async (req: any, res: Response) => {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
|
||||
const { warehouse_code, keyword } = req.query;
|
||||
const conditions = ["ist.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
conditions.push("COALESCE(CAST(NULLIF(ist.current_qty, '') AS numeric), 0) > 0");
|
||||
|
||||
if (warehouse_code && warehouse_code !== "all") {
|
||||
conditions.push(`ist.warehouse_code = $${idx++}`);
|
||||
params.push(warehouse_code);
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(
|
||||
ist.item_code ILIKE $${idx}
|
||||
OR COALESCE(ii.item_name, '') ILIKE $${idx}
|
||||
OR COALESCE(ii.item_number, '') ILIKE $${idx}
|
||||
)`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
ist.id,
|
||||
ist.item_code,
|
||||
COALESCE(ii.item_name, ist.item_code) AS item_name,
|
||||
COALESCE(ii.item_number, ist.item_code) AS item_number,
|
||||
COALESCE(ii.size, '') AS spec,
|
||||
COALESCE(NULLIF(ii.inventory_unit, ''), NULLIF(ii.unit, ''), 'EA') AS unit,
|
||||
ist.warehouse_code,
|
||||
COALESCE(wi.warehouse_name, ist.warehouse_code) AS warehouse_name,
|
||||
COALESCE(ist.location_code, '') AS location_code,
|
||||
ist.current_qty
|
||||
FROM inventory_stock ist
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, size, unit, inventory_unit, company_code
|
||||
FROM item_info
|
||||
ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON ist.item_code = ii.item_number AND ist.company_code = ii.company_code
|
||||
LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code
|
||||
AND ist.company_code = wi.company_code
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY COALESCE(ii.item_name, ist.item_code)
|
||||
LIMIT 500`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error("[pop/inventory/move] stock-list 오류:", error);
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/pop/inventory/move/commit
|
||||
* 재고이동 일괄 확정
|
||||
*
|
||||
* Body: { items: CommitItem[], to_warehouse_code, to_location_code? }
|
||||
*
|
||||
* 가드:
|
||||
* - qty > 0
|
||||
* - qty <= 출발.current_qty (음수 차단)
|
||||
* - 출발창고 == 도착창고 + 동일 location → 차단
|
||||
* - 출발창고 == 도착창고 + 다른 location → warehouse_location 등록 시만 허용
|
||||
* - 출발창고 != 도착창고 → 허용
|
||||
*
|
||||
* 처리:
|
||||
* - 출발 stock current_qty -= qty
|
||||
* - 도착 stock UPSERT (있으면 += qty, 없으면 INSERT)
|
||||
* - inventory_history 2건 INSERT (transaction_type='이동')
|
||||
* - cart_items cart_type='inventory-move' saved → cancelled
|
||||
*/
|
||||
export const commitMove = async (req: any, res: Response) => {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
const userName = req.user?.userName || userId;
|
||||
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
|
||||
const { items, to_warehouse_code, to_location_code } = req.body as CommitBody;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "이동 항목이 없습니다" });
|
||||
}
|
||||
if (!to_warehouse_code) {
|
||||
return res.status(400).json({ success: false, message: "도착 창고를 선택하세요" });
|
||||
}
|
||||
|
||||
const toLocation = to_location_code || "";
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
let moveCount = 0;
|
||||
const results: Array<{ stock_id: string; status: string; reason?: string }> = [];
|
||||
|
||||
for (const item of items) {
|
||||
// 1. 출발 stock 검증 + company_code 필터
|
||||
const stockCheck = await client.query(
|
||||
`SELECT id, item_code, warehouse_code, location_code,
|
||||
COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS current_qty
|
||||
FROM inventory_stock
|
||||
WHERE id = $1 AND company_code = $2`,
|
||||
[item.stock_id, companyCode]
|
||||
);
|
||||
|
||||
if (stockCheck.rowCount === 0) {
|
||||
results.push({ stock_id: item.stock_id, status: "not_found" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const stock = stockCheck.rows[0];
|
||||
const currentQty = parseFloat(stock.current_qty) || 0;
|
||||
const qty = item.qty;
|
||||
|
||||
// 2. qty 검증
|
||||
if (!qty || qty <= 0) {
|
||||
results.push({ stock_id: item.stock_id, status: "invalid_qty" });
|
||||
continue;
|
||||
}
|
||||
if (qty > currentQty) {
|
||||
results.push({ stock_id: item.stock_id, status: "exceeds_stock", reason: `재고 ${currentQty} 초과` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 동일창고 가드
|
||||
const fromLocation = stock.location_code || "";
|
||||
const sameWarehouse = stock.warehouse_code === to_warehouse_code;
|
||||
const sameLocation = fromLocation === toLocation;
|
||||
|
||||
if (sameWarehouse && sameLocation) {
|
||||
results.push({ stock_id: item.stock_id, status: "same_position", reason: "출발과 도착이 동일" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sameWarehouse && !sameLocation) {
|
||||
// 같은 창고 다른 location → warehouse_location 등록 필수
|
||||
const locCheck = await client.query(
|
||||
`SELECT 1 FROM warehouse_location
|
||||
WHERE company_code = $1 AND warehouse_code = $2
|
||||
LIMIT 1`,
|
||||
[companyCode, to_warehouse_code]
|
||||
);
|
||||
if (locCheck.rowCount === 0) {
|
||||
results.push({
|
||||
stock_id: item.stock_id,
|
||||
status: "no_rack",
|
||||
reason: "동일창고 이동은 렉구조 등록 필요"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 출발 stock 차감
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty,'') AS numeric),0) - $1, 0) AS text),
|
||||
updated_date = NOW(), writer = $2
|
||||
WHERE id = $3 AND company_code = $4`,
|
||||
[String(qty), userId, item.stock_id, companyCode]
|
||||
);
|
||||
|
||||
// 5. 도착 stock UPSERT
|
||||
const existingDest = await client.query(
|
||||
`SELECT id, COALESCE(CAST(NULLIF(current_qty,'') AS numeric),0) AS current_qty
|
||||
FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code,'') = COALESCE($3,'')
|
||||
AND COALESCE(location_code,'') = COALESCE($4,'')
|
||||
LIMIT 1`,
|
||||
[companyCode, stock.item_code, to_warehouse_code, toLocation]
|
||||
);
|
||||
|
||||
let destBalance: number;
|
||||
if (existingDest.rows.length > 0) {
|
||||
const destPrev = parseFloat(existingDest.rows[0].current_qty) || 0;
|
||||
destBalance = destPrev + qty;
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty,'') AS numeric),0) + $1 AS text),
|
||||
updated_date = NOW(), writer = $2
|
||||
WHERE id = $3 AND company_code = $4`,
|
||||
[String(qty), userId, existingDest.rows[0].id, companyCode]
|
||||
);
|
||||
} else {
|
||||
destBalance = qty;
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, created_date, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), $6)`,
|
||||
[companyCode, stock.item_code, to_warehouse_code, toLocation, String(qty), userId]
|
||||
);
|
||||
}
|
||||
|
||||
const fromBalance = Math.max(currentQty - qty, 0);
|
||||
|
||||
// 6. inventory_history 2건 INSERT
|
||||
// 출발 (-qty)
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, reason, remark, writer, manager_name, created_date)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4,
|
||||
'이동', NOW(), $5, $6, NULL, NULL, $7, $8, NOW())`,
|
||||
[companyCode, stock.item_code, stock.warehouse_code, fromLocation,
|
||||
String(-qty), String(fromBalance), userId, userName]
|
||||
);
|
||||
// 도착 (+qty)
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, reason, remark, writer, manager_name, created_date)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4,
|
||||
'이동', NOW(), $5, $6, NULL, NULL, $7, $8, NOW())`,
|
||||
[companyCode, stock.item_code, to_warehouse_code, toLocation,
|
||||
String(qty), String(destBalance), userId, userName]
|
||||
);
|
||||
|
||||
moveCount++;
|
||||
results.push({ stock_id: item.stock_id, status: "ok" });
|
||||
}
|
||||
|
||||
// 7. 임시저장 cart_items 자동 cancelled
|
||||
await client.query(
|
||||
`UPDATE cart_items SET status = 'cancelled', updated_date = NOW()
|
||||
WHERE company_code = $1 AND cart_type = $2 AND status = 'saved'`,
|
||||
[companyCode, CART_TYPE]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("[pop/inventory/move] commit 완료", {
|
||||
companyCode, userId, moveCount, total: items.length
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `이동 ${moveCount}건 처리 완료`,
|
||||
data: { moveCount, results }
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
logger.error("[pop/inventory/move] commit 오류:", error);
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/pop/inventory/move/temp-load
|
||||
* 임시저장 불러오기 (cart_type='inventory-move' + status='saved')
|
||||
*/
|
||||
export const loadTempMove = async (req: any, res: Response) => {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, row_data, row_key, created_date
|
||||
FROM cart_items
|
||||
WHERE company_code = $1 AND cart_type = $2 AND status = 'saved'
|
||||
ORDER BY created_date`,
|
||||
[companyCode, CART_TYPE]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error("[pop/inventory/move] temp-load 오류:", error);
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/pop/inventory/move/temp-save
|
||||
* 임시저장 일괄 덮어쓰기
|
||||
*
|
||||
* Body: { items: any[] }
|
||||
*/
|
||||
export const saveTempMove = async (req: any, res: Response) => {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
|
||||
const { items } = req.body as { items: any[] };
|
||||
if (!Array.isArray(items)) {
|
||||
return res.status(400).json({ success: false, message: "items 배열이 필요합니다" });
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
await client.query(
|
||||
`UPDATE cart_items SET status = 'cancelled', updated_date = NOW()
|
||||
WHERE company_code = $1 AND cart_type = $2 AND status = 'saved'`,
|
||||
[companyCode, CART_TYPE]
|
||||
);
|
||||
|
||||
void userId;
|
||||
for (const item of items) {
|
||||
await client.query(
|
||||
`INSERT INTO cart_items (id, company_code, cart_type, row_key, row_data, status, created_date)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, 'saved', NOW())`,
|
||||
[
|
||||
companyCode,
|
||||
CART_TYPE,
|
||||
item?.row_key || item?.stock_id || item?.stockId || "",
|
||||
JSON.stringify(item)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
return res.json({ success: true, message: `${items.length}건 임시저장 완료` });
|
||||
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
logger.error("[pop/inventory/move] temp-save 오류:", error);
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
loadTempAdjust,
|
||||
saveTempAdjust,
|
||||
} from "../controllers/popInventoryAdjustController";
|
||||
import {
|
||||
getMoveStockList,
|
||||
commitMove,
|
||||
loadTempMove,
|
||||
saveTempMove,
|
||||
} from "../controllers/popInventoryMoveController";
|
||||
import { getInOutHistory } from "../controllers/popInOutHistoryController";
|
||||
import {
|
||||
getInboundDetail,
|
||||
@@ -31,6 +37,14 @@ router.get("/adjust/temp-load", loadTempAdjust);
|
||||
// 임시저장 일괄 덮어쓰기 (자동 임시저장)
|
||||
router.post("/adjust/temp-save", saveTempAdjust);
|
||||
|
||||
// ============================================================
|
||||
// 신 POP 재고이동 (v2) — popInventoryMoveController
|
||||
// ============================================================
|
||||
router.get("/move/stock-list", getMoveStockList);
|
||||
router.post("/move/commit", commitMove);
|
||||
router.get("/move/temp-load", loadTempMove);
|
||||
router.post("/move/temp-save", saveTempMove);
|
||||
|
||||
// 입출고관리 통합 이력 조회 (3-way UNION: inbound + outbound + inventory_history)
|
||||
router.get("/inout-history", getInOutHistory);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user