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:
kmh
2026-05-11 12:03:02 +09:00
parent d5320d86e7
commit 2a6577701b
47 changed files with 6008 additions and 82 deletions

View File

@@ -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
)
)
`;

View 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();
}
};

View File

@@ -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);