Merge remote-tracking branch 'origin/jskim-node' into gbpark-node
This commit is contained in:
@@ -158,12 +158,14 @@ import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지
|
||||
import cuttingPlanRoutes from "./routes/cuttingPlanRoutes"; // 절단계획 관리
|
||||
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
||||
import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별)
|
||||
import reportCellValueRoutes from "./routes/reportCellValueRoutes"; // 리포트 셀 커스텀 입력값 (input 셀)
|
||||
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
import systemNoticeRoutes from "./routes/systemNoticeRoutes"; // 시스템 공지
|
||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
|
||||
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
|
||||
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
|
||||
import outsourcingOutboundRoutes from "./routes/outsourcingOutboundRoutes"; // 외주출고
|
||||
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
|
||||
import quoteRoutes from "./routes/quoteRoutes"; // 견적관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
@@ -383,11 +385,13 @@ app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
|
||||
app.use("/api/cutting-plan", cuttingPlanRoutes); // 절단계획 관리
|
||||
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||
app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장)
|
||||
app.use("/api/report-cell-values", reportCellValueRoutes); // 리포트 셀 커스텀 입력값
|
||||
app.use("/api/system-notice", systemNoticeRoutes); // 시스템 공지
|
||||
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
app.use("/api/design", designRoutes); // 설계 모듈
|
||||
app.use("/api/receiving", receivingRoutes); // 입고관리
|
||||
app.use("/api/outbound", outboundRoutes); // 출고관리
|
||||
app.use("/api/outsourcing-outbound", outsourcingOutboundRoutes); // 외주출고
|
||||
app.use("/api/quotes", quoteRoutes); // 견적관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
|
||||
@@ -2142,6 +2142,35 @@ export const getDepartmentList = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/name-map
|
||||
* 사용자 ID → 이름 매핑만 반환하는 경량 엔드포인트
|
||||
* 목적: 이력(writer/created_by 등)에 찍힌 user_id를 이름으로 표시하기 위함
|
||||
* 보안: 민감 정보(전화번호/이메일 등) 미포함, 인증된 사용자면 누구나 조회
|
||||
* 회사 필터 없음 — 최고 관리자 계정(company_code='*')도 포함
|
||||
*/
|
||||
export const getUserNameMap = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT user_id, user_name FROM user_info WHERE user_id IS NOT NULL`,
|
||||
[]
|
||||
);
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: rows.map((r: any) => ({
|
||||
user_id: r.user_id,
|
||||
user_name: r.user_name,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("사용자 이름 맵 조회 실패", { error });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "사용자 이름 맵 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/:userId
|
||||
* 사용자 상세 조회 API
|
||||
|
||||
@@ -56,45 +56,75 @@ export async function getProductionReportData(req: any, res: Response): Promise<
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "wi", idx);
|
||||
const cf = buildCompanyFilter(companyCode, "wop", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx);
|
||||
const dateExpr = "COALESCE(NULLIF(wop.started_at, ''), wop.created_date::date::text)";
|
||||
const df = buildDateFilter(startDate, endDate, dateExpr, idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
// 실제 공정별 생산 데이터는 work_order_process에 있음
|
||||
// (work_instruction.routing은 routing_version_id UUID일 뿐이라 공정명이 아님)
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(wi.start_date, wi.created_date::date::text) as date,
|
||||
COALESCE(wi.routing, '미지정') as process,
|
||||
COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment,
|
||||
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
|
||||
COALESCE(wi.worker, '미지정') as worker,
|
||||
CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty",
|
||||
COALESCE(pr.production_qty, 0) as "prodQty",
|
||||
COALESCE(pr.defect_qty, 0) as "defectQty",
|
||||
0 as "runTime",
|
||||
0 as "downTime",
|
||||
wi.status,
|
||||
wi.company_code
|
||||
FROM work_instruction wi
|
||||
LEFT JOIN (
|
||||
SELECT wo_id, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty,
|
||||
SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty
|
||||
FROM production_record GROUP BY wo_id, company_code
|
||||
) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (equipment_code, company_code)
|
||||
equipment_code, equipment_name, equipment_type, company_code
|
||||
FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC
|
||||
) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
|
||||
COALESCE(NULLIF(wop.started_at, ''), wop.created_date::date::text) as date,
|
||||
COALESCE(NULLIF(wop.process_name, ''), NULLIF(wop.process_code, ''), '미지정') as process,
|
||||
COALESCE(NULLIF(em.equipment_name, ''), NULLIF(em.equipment_code, ''), '미지정') as equipment,
|
||||
COALESCE(NULLIF(ii.item_name, ''), NULLIF(ii.item_number, ''), '미지정') as item,
|
||||
COALESCE(NULLIF(wi.worker, ''), '미지정') as worker,
|
||||
CAST(COALESCE(NULLIF(wop.plan_qty, ''), '0') AS numeric) as "planQty",
|
||||
CAST(COALESCE(NULLIF(wop.good_qty, ''), '0') AS numeric) as "prodQty",
|
||||
CAST(COALESCE(NULLIF(wop.defect_qty, ''), '0') AS numeric) as "defectQty",
|
||||
CASE
|
||||
WHEN NULLIF(wop.started_at, '') IS NOT NULL
|
||||
AND NULLIF(wop.completed_at, '') IS NOT NULL
|
||||
THEN GREATEST(
|
||||
EXTRACT(EPOCH FROM (wop.completed_at::timestamp - wop.started_at::timestamp)) / 3600.0,
|
||||
0
|
||||
)
|
||||
ELSE 0
|
||||
END as "runTime",
|
||||
CAST(COALESCE(NULLIF(wop.total_paused_time, ''), '0') AS numeric) / 3600.0 as "downTime",
|
||||
wop.status,
|
||||
wop.company_code
|
||||
FROM work_order_process wop
|
||||
LEFT JOIN work_instruction wi
|
||||
ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT equipment_code, equipment_name
|
||||
FROM equipment_mng
|
||||
WHERE company_code = wi.company_code
|
||||
AND (id = wi.equipment_id OR equipment_code = wi.equipment_id
|
||||
OR id = wop.equipment_code OR equipment_code = wop.equipment_code)
|
||||
ORDER BY (id = wi.equipment_id OR id = wop.equipment_code) DESC, created_date DESC
|
||||
LIMIT 1
|
||||
) em ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT ii_inner.item_number, ii_inner.item_name
|
||||
FROM item_info ii_inner
|
||||
WHERE ii_inner.company_code = wi.company_code
|
||||
AND (
|
||||
(NULLIF(wi.item_id, '') IS NOT NULL
|
||||
AND (ii_inner.id = wi.item_id OR ii_inner.item_number = wi.item_id))
|
||||
OR ii_inner.item_number = (
|
||||
SELECT wid.item_number
|
||||
FROM work_instruction_detail wid
|
||||
WHERE wid.work_instruction_id = wi.id
|
||||
AND wid.company_code = wi.company_code
|
||||
AND NULLIF(wid.item_number, '') IS NOT NULL
|
||||
ORDER BY wid.created_date ASC
|
||||
LIMIT 1
|
||||
)
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN ii_inner.id = wi.item_id THEN 1
|
||||
WHEN ii_inner.item_number = wi.item_id THEN 2
|
||||
ELSE 3 END,
|
||||
ii_inner.created_date DESC
|
||||
LIMIT 1
|
||||
) ii ON true
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
@@ -174,6 +174,7 @@ export async function getMaterialStatus(
|
||||
ii.item_name AS material_name,
|
||||
ii.item_number AS material_code,
|
||||
ii.unit AS material_unit,
|
||||
ii.inventory_unit AS material_inventory_unit,
|
||||
COALESCE(ii.width::text, '') AS material_width,
|
||||
COALESCE(ii.height::text, '') AS material_height,
|
||||
COALESCE(ii.thickness::text, '') AS material_thickness
|
||||
@@ -220,7 +221,11 @@ export async function getMaterialStatus(
|
||||
materialCode:
|
||||
bomRow.material_code || bomRow.child_item_id,
|
||||
materialName: bomRow.material_name || "알 수 없음",
|
||||
unit: bomRow.bom_unit || bomRow.material_unit || "EA",
|
||||
unit:
|
||||
bomRow.material_inventory_unit ||
|
||||
bomRow.bom_unit ||
|
||||
bomRow.material_unit ||
|
||||
"EA",
|
||||
requiredQty,
|
||||
width: bomRow.material_width || "",
|
||||
height: bomRow.material_height || "",
|
||||
@@ -260,12 +265,16 @@ export async function getMaterialStatus(
|
||||
}
|
||||
|
||||
const stockQuery = `
|
||||
SELECT
|
||||
SELECT
|
||||
s.item_code,
|
||||
s.warehouse_code,
|
||||
w.warehouse_name,
|
||||
s.location_code,
|
||||
COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty
|
||||
FROM inventory_stock s
|
||||
LEFT JOIN warehouse_info w
|
||||
ON w.warehouse_code = s.warehouse_code
|
||||
AND w.company_code = s.company_code
|
||||
WHERE ${stockConditions.join(" AND ")}
|
||||
AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0
|
||||
ORDER BY s.item_code, s.warehouse_code, s.location_code
|
||||
@@ -277,7 +286,7 @@ export async function getMaterialStatus(
|
||||
// item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음)
|
||||
const stockByItem: Record<
|
||||
string,
|
||||
{ location: string; warehouse: string; qty: number }[]
|
||||
{ location: string; warehouse: string; warehouse_name: string; qty: number }[]
|
||||
> = {};
|
||||
|
||||
for (const stockRow of stockResult.rows) {
|
||||
@@ -288,6 +297,7 @@ export async function getMaterialStatus(
|
||||
stockByItem[code].push({
|
||||
location: stockRow.location_code || "",
|
||||
warehouse: stockRow.warehouse_code || "",
|
||||
warehouse_name: stockRow.warehouse_name || "",
|
||||
qty: Number(stockRow.current_qty),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -512,13 +512,36 @@ export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Respo
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { moldCode } = req.params;
|
||||
|
||||
// 카테고리 코드/영문코드/한글라벨 모두 대응
|
||||
// 먼저 카테고리 값 조회하여 매핑
|
||||
// mold_serial.status + mold_mng.operation_status 양쪽 카테고리 모두 조회
|
||||
const catSql = `SELECT value_code, value_label FROM category_values
|
||||
WHERE ((table_name='mold_serial' AND column_name='status') OR (table_name='mold_mng' AND column_name='operation_status'))
|
||||
AND company_code=$1`;
|
||||
const catRows = await query(catSql, [companyCode]);
|
||||
|
||||
// 카테고리 라벨 기준으로 그룹핑할 코드 목록 생성
|
||||
const codesByLabel: Record<string, string[]> = { "사용중": ["IN_USE"], "수리중": ["REPAIR"], "보관중": ["STORED"], "폐기": ["DISPOSED"] };
|
||||
for (const cat of catRows) {
|
||||
const label = cat.value_label || "";
|
||||
if (label.includes("사용")) (codesByLabel["사용중"] = codesByLabel["사용중"] || []).push(cat.value_code);
|
||||
else if (label.includes("수리")) (codesByLabel["수리중"] = codesByLabel["수리중"] || []).push(cat.value_code);
|
||||
else if (label.includes("보관") || label.includes("미사용")) (codesByLabel["보관중"] = codesByLabel["보관중"] || []).push(cat.value_code);
|
||||
else if (label.includes("폐기")) (codesByLabel["폐기"] = codesByLabel["폐기"] || []).push(cat.value_code);
|
||||
}
|
||||
|
||||
const inUseCodes = codesByLabel["사용중"].map(c => `'${c}'`).join(",");
|
||||
const repairCodes = codesByLabel["수리중"].map(c => `'${c}'`).join(",");
|
||||
const storedCodes = codesByLabel["보관중"].map(c => `'${c}'`).join(",");
|
||||
const disposedCodes = codesByLabel["폐기"].map(c => `'${c}'`).join(",");
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use,
|
||||
COUNT(*) FILTER (WHERE status = 'REPAIR') as repair,
|
||||
COUNT(*) FILTER (WHERE status = 'STORED') as stored,
|
||||
COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed
|
||||
COUNT(*) FILTER (WHERE status IN (${inUseCodes})) as in_use,
|
||||
COUNT(*) FILTER (WHERE status IN (${repairCodes})) as repair,
|
||||
COUNT(*) FILTER (WHERE status IN (${storedCodes})) as stored,
|
||||
COUNT(*) FILTER (WHERE status IN (${disposedCodes})) as disposed
|
||||
FROM mold_serial
|
||||
WHERE mold_code = $1 AND company_code = $2
|
||||
`;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import type { AuthenticatedRequest } from "../types/auth";
|
||||
import { adjustInventory } from "../utils/inventoryUtils";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 출고 목록 조회
|
||||
@@ -324,6 +325,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
// 출고 수정
|
||||
export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
@@ -341,8 +345,90 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
memo,
|
||||
} = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 변경 전 값 조회
|
||||
const oldRes = await client.query(
|
||||
`SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode],
|
||||
);
|
||||
if (oldRes.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
const old = oldRes.rows[0];
|
||||
const oldQty = Number(old.outbound_qty) || 0;
|
||||
const oldWhCode = old.warehouse_code || null;
|
||||
const oldLocCode = old.location_code || null;
|
||||
const itemCode = old.item_code || old.item_number || null;
|
||||
const outboundNumber = old.outbound_number;
|
||||
|
||||
const newQty =
|
||||
outbound_qty !== undefined && outbound_qty !== null
|
||||
? Number(outbound_qty)
|
||||
: oldQty;
|
||||
const newWhCode =
|
||||
warehouse_code !== undefined ? warehouse_code : oldWhCode;
|
||||
const newLocCode =
|
||||
location_code !== undefined ? location_code : oldLocCode;
|
||||
|
||||
// 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시
|
||||
const qtyChanged = newQty !== oldQty;
|
||||
const whChanged =
|
||||
(newWhCode || "") !== (oldWhCode || "") ||
|
||||
(newLocCode || "") !== (oldLocCode || "");
|
||||
|
||||
if (itemCode && (qtyChanged || whChanged)) {
|
||||
if (whChanged) {
|
||||
// 기존 창고 복구
|
||||
if (oldQty > 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: oldWhCode,
|
||||
locCode: oldLocCode,
|
||||
delta: +oldQty,
|
||||
transactionType: "출고취소",
|
||||
remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}`,
|
||||
});
|
||||
}
|
||||
// 신규 창고 차감 (재고부족 검증)
|
||||
if (newQty > 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: newWhCode,
|
||||
locCode: newLocCode,
|
||||
delta: -newQty,
|
||||
transactionType: "출고수정",
|
||||
remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}, 수량 ${oldQty}→${newQty}`,
|
||||
validateStockEnough: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 창고 동일, 수량만 변경: 기존 복구(+oldQty) + 신규 차감(-newQty) = delta(+복구/-추가차감)
|
||||
const delta = oldQty - newQty;
|
||||
if (delta !== 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: newWhCode,
|
||||
locCode: newLocCode,
|
||||
delta,
|
||||
transactionType: "출고수정",
|
||||
remark: `출고수정 (${outboundNumber}) 수량 ${oldQty}→${newQty}`,
|
||||
validateStockEnough: delta < 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`UPDATE outbound_mng SET
|
||||
outbound_date = COALESCE($1, outbound_date),
|
||||
outbound_qty = COALESCE($2, outbound_qty),
|
||||
@@ -375,45 +461,95 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출고 수정", { companyCode, userId, id });
|
||||
logger.info("출고 수정", {
|
||||
companyCode,
|
||||
userId,
|
||||
id,
|
||||
oldQty,
|
||||
newQty,
|
||||
oldWhCode,
|
||||
newWhCode,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("출고 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 삭제
|
||||
// 출고 삭제 (재고 복구 + '출고취소' 이력 기록 포함)
|
||||
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 대상 출고 조회
|
||||
const oldRes = await client.query(
|
||||
`SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
if (oldRes.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
const old = oldRes.rows[0];
|
||||
const itemCode = old.item_code || old.item_number || null;
|
||||
const whCode = old.warehouse_code || null;
|
||||
const locCode = old.location_code || null;
|
||||
const qty = Number(old.outbound_qty) || 0;
|
||||
const outboundNumber = old.outbound_number;
|
||||
|
||||
logger.info("출고 삭제", { companyCode, id });
|
||||
// 재고 복구 + 이력
|
||||
if (itemCode && qty > 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode,
|
||||
locCode,
|
||||
delta: +qty,
|
||||
transactionType: "출고취소",
|
||||
remark: `출고 삭제 (${outboundNumber})`,
|
||||
});
|
||||
} else {
|
||||
logger.warn("출고 삭제 - 재고 복구 스킵", {
|
||||
companyCode,
|
||||
id,
|
||||
itemCode,
|
||||
qty,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode],
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출고 삭제", { companyCode, userId, id, itemCode, qty });
|
||||
|
||||
return res.json({ success: true, message: "삭제 완료" });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("출고 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
473
backend-node/src/controllers/outsourcingOutboundController.ts
Normal file
473
backend-node/src/controllers/outsourcingOutboundController.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* 외주출고 컨트롤러
|
||||
*
|
||||
* 이전 공정이 완료되고 다음 공정이 외주 공정이면
|
||||
* 자동으로 외주출고 대상 목록에 표시 → 출고 처리
|
||||
*
|
||||
* 출고 데이터는 기존 outbound_mng 테이블 재사용
|
||||
* (outbound_type='외주출고', source_type='work_order_process')
|
||||
*/
|
||||
|
||||
import type { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import type { AuthenticatedRequest } from "../types/auth";
|
||||
import { adjustInventory } from "../utils/inventoryUtils";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 외주출고 대상 자동 조회
|
||||
* GET /api/outsourcing-outbound/candidates
|
||||
*
|
||||
* 이전 공정 완료 + 다음 공정이 외주 공정인 건 자동 표시
|
||||
*/
|
||||
export async function getCandidates(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
let keywordCondition = "";
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
keywordCondition = `AND (
|
||||
wi.instruction_no ILIKE $${paramIdx}
|
||||
OR wi.item_name ILIKE $${paramIdx}
|
||||
OR wi.item_code ILIKE $${paramIdx}
|
||||
OR sm.subcontractor_name ILIKE $${paramIdx}
|
||||
)`;
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const companyFilter = companyCode !== "*"
|
||||
? `wop_done.company_code = $1`
|
||||
: `1=1`;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
wop_done.id AS completed_process_id,
|
||||
wop_done.wo_id,
|
||||
wop_done.seq_no AS completed_seq_no,
|
||||
wop_done.process_code AS completed_process_code,
|
||||
COALESCE(pm_done.process_name, wop_done.process_name, wop_done.process_code) AS completed_process_name,
|
||||
COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) AS good_qty,
|
||||
wop_next.id AS next_process_id,
|
||||
wop_next.seq_no AS next_seq_no,
|
||||
wop_next.process_code AS next_process_code,
|
||||
COALESCE(pm_next.process_name, wop_next.process_name, wop_next.process_code) AS next_process_name,
|
||||
wop_next.status AS next_status,
|
||||
wi.instruction_no,
|
||||
wi.item_code,
|
||||
wi.item_name,
|
||||
ii.size AS spec,
|
||||
ii.material,
|
||||
ii.inventory_unit AS unit,
|
||||
sm.id AS subcontractor_id,
|
||||
sm.subcontractor_code,
|
||||
sm.subcontractor_name
|
||||
FROM work_order_process wop_done
|
||||
INNER JOIN work_instruction wi
|
||||
ON wop_done.wo_id = wi.id
|
||||
AND wop_done.company_code = wi.company_code
|
||||
-- 다음 공정 (바로 다음 seq_no)
|
||||
INNER JOIN LATERAL (
|
||||
SELECT wop2.*
|
||||
FROM work_order_process wop2
|
||||
WHERE wop2.wo_id = wop_done.wo_id
|
||||
AND wop2.company_code = wop_done.company_code
|
||||
AND wop2.parent_process_id IS NULL
|
||||
AND CAST(wop2.seq_no AS int) > CAST(wop_done.seq_no AS int)
|
||||
ORDER BY CAST(wop2.seq_no AS int)
|
||||
LIMIT 1
|
||||
) wop_next ON TRUE
|
||||
-- 다음 공정이 외주인지 확인
|
||||
INNER JOIN item_routing_subcontractor irs
|
||||
ON irs.routing_detail_id = wop_next.routing_detail_id
|
||||
INNER JOIN subcontractor_mng sm
|
||||
ON irs.subcontractor_id = sm.id
|
||||
LEFT JOIN item_info ii
|
||||
ON wi.item_code = ii.item_number AND wi.company_code = ii.company_code
|
||||
LEFT JOIN process_mng pm_done
|
||||
ON wop_done.process_code = pm_done.process_code AND wop_done.company_code = pm_done.company_code
|
||||
LEFT JOIN process_mng pm_next
|
||||
ON wop_next.process_code = pm_next.process_code AND wop_next.company_code = pm_next.company_code
|
||||
WHERE ${companyFilter}
|
||||
AND wop_done.parent_process_id IS NULL
|
||||
AND wop_done.status IN ('completed', 'acceptable')
|
||||
AND COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) > 0
|
||||
-- 아직 외주출고 등록 안 된 건만
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM outbound_mng om
|
||||
WHERE om.outbound_type = '외주출고'
|
||||
AND om.source_type = 'work_order_process'
|
||||
AND om.source_id = wop_done.id
|
||||
${companyCode !== "*" ? "AND om.company_code = $1" : ""}
|
||||
)
|
||||
${keywordCondition}
|
||||
ORDER BY wi.instruction_no, CAST(wop_done.seq_no AS int)
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("외주출고 대상 조회", { companyCode, count: result.rowCount });
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("외주출고 대상 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주출고 목록 조회
|
||||
* GET /api/outsourcing-outbound/list
|
||||
*/
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { outbound_status, search_keyword, date_from, date_to } = req.query;
|
||||
|
||||
const conditions: string[] = ["om.outbound_type = '외주출고'"];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`om.company_code = $${paramIdx}`);
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (outbound_status && outbound_status !== "all") {
|
||||
conditions.push(`om.outbound_status = $${paramIdx}`);
|
||||
params.push(outbound_status);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (search_keyword) {
|
||||
conditions.push(`(
|
||||
om.outbound_number ILIKE $${paramIdx}
|
||||
OR om.item_name ILIKE $${paramIdx}
|
||||
OR om.item_code ILIKE $${paramIdx}
|
||||
OR om.customer_name ILIKE $${paramIdx}
|
||||
OR om.reference_number ILIKE $${paramIdx}
|
||||
)`);
|
||||
params.push(`%${search_keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (date_from) {
|
||||
conditions.push(`om.outbound_date >= $${paramIdx}`);
|
||||
params.push(date_from);
|
||||
paramIdx++;
|
||||
}
|
||||
if (date_to) {
|
||||
conditions.push(`om.outbound_date <= $${paramIdx}`);
|
||||
params.push(date_to);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(" AND ")}`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT om.*, wh.warehouse_name
|
||||
FROM outbound_mng om
|
||||
LEFT JOIN warehouse_info wh ON om.warehouse_code = wh.warehouse_code AND om.company_code = wh.company_code
|
||||
${whereClause}
|
||||
ORDER BY om.created_date DESC`,
|
||||
params,
|
||||
);
|
||||
|
||||
logger.info("외주출고 목록 조회", { companyCode, count: result.rowCount });
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("외주출고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주출고 등록
|
||||
* POST /api/outsourcing-outbound
|
||||
*/
|
||||
export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const {
|
||||
items,
|
||||
outbound_number,
|
||||
outbound_date,
|
||||
warehouse_code,
|
||||
location_code,
|
||||
manager_id,
|
||||
memo,
|
||||
} = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
const insertedRows: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO outbound_mng (
|
||||
id, company_code, outbound_number, outbound_type, outbound_date,
|
||||
reference_number, customer_code, customer_name,
|
||||
item_code, item_name, specification, material, unit,
|
||||
outbound_qty, unit_price, total_amount,
|
||||
warehouse_code, location_code,
|
||||
outbound_status, manager_id, memo,
|
||||
source_type, source_id,
|
||||
created_date, created_by, writer, status
|
||||
) VALUES (
|
||||
gen_random_uuid()::text, $1, $2, '외주출고', $3,
|
||||
$4, $5, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, 0, 0,
|
||||
$13, $14,
|
||||
'출고완료', $15, $16,
|
||||
'work_order_process', $17,
|
||||
NOW(), $18, $18, '출고'
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
outbound_number || item.outbound_number,
|
||||
outbound_date || item.outbound_date,
|
||||
item.reference_number || null, // 작업지시번호
|
||||
item.subcontractor_code || null, // 외주사코드 → customer_code
|
||||
item.subcontractor_name || null, // 외주사명 → customer_name
|
||||
item.item_code || null,
|
||||
item.item_name || null,
|
||||
item.spec || null,
|
||||
item.material || null,
|
||||
item.unit || null,
|
||||
item.outbound_qty || 0,
|
||||
warehouse_code || item.warehouse_code || null,
|
||||
location_code || item.location_code || null,
|
||||
manager_id || item.manager_id || null,
|
||||
memo || item.memo || null,
|
||||
item.completed_process_id || null, // source_id = 완료된 공정 ID
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// 재고 차감
|
||||
const itemCode = item.item_code || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
const locCode = location_code || item.location_code || null;
|
||||
const outQty = Number(item.outbound_qty) || 0;
|
||||
|
||||
if (itemCode && outQty > 0 && whCode) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode,
|
||||
locCode,
|
||||
delta: -outQty,
|
||||
transactionType: "외주출고",
|
||||
remark: `외주출고 (${outbound_number || ""}) → ${item.subcontractor_name || ""}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("외주출고 등록 완료", {
|
||||
companyCode,
|
||||
userId,
|
||||
count: insertedRows.length,
|
||||
outbound_number,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: insertedRows,
|
||||
message: `${insertedRows.length}건 외주출고 등록 완료`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("외주출고 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주출고 수정
|
||||
* PUT /api/outsourcing-outbound/:id
|
||||
*/
|
||||
export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const { outbound_date, outbound_qty, warehouse_code, location_code, memo } = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
const companyCondition = companyCode === "*" ? "" : `AND company_code = '${companyCode}'`;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE outbound_mng SET
|
||||
outbound_date = COALESCE($1::date, outbound_date),
|
||||
outbound_qty = COALESCE($2::numeric, outbound_qty),
|
||||
warehouse_code = COALESCE($3, warehouse_code),
|
||||
location_code = COALESCE($4, location_code),
|
||||
memo = COALESCE($5, memo),
|
||||
updated_date = NOW(),
|
||||
updated_by = $6
|
||||
WHERE id = $7 ${companyCondition}
|
||||
RETURNING *`,
|
||||
[outbound_date, outbound_qty, warehouse_code, location_code, memo, userId, id],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("외주출고 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주출고 삭제
|
||||
* DELETE /api/outsourcing-outbound/:id
|
||||
*/
|
||||
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 삭제 전 데이터 조회 (재고 복구용)
|
||||
const companyCondition = companyCode === "*" ? "" : `AND company_code = $2`;
|
||||
const queryParams = companyCode === "*" ? [id] : [id, companyCode];
|
||||
|
||||
const oldRes = await client.query(
|
||||
`SELECT * FROM outbound_mng WHERE id = $1 ${companyCondition}`,
|
||||
queryParams,
|
||||
);
|
||||
|
||||
if (oldRes.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const old = oldRes.rows[0];
|
||||
const itemCode = old.item_code || null;
|
||||
const whCode = old.warehouse_code || null;
|
||||
const locCode = old.location_code || null;
|
||||
const oldQty = Number(old.outbound_qty) || 0;
|
||||
|
||||
// 재고 복구
|
||||
if (itemCode && oldQty > 0 && whCode) {
|
||||
await adjustInventory(client, {
|
||||
companyCode: old.company_code,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode,
|
||||
locCode,
|
||||
delta: +oldQty,
|
||||
transactionType: "외주출고취소",
|
||||
remark: `외주출고 삭제 (${old.outbound_number || ""})`,
|
||||
});
|
||||
}
|
||||
|
||||
// 삭제
|
||||
await client.query(
|
||||
`DELETE FROM outbound_mng WHERE id = $1 ${companyCondition}`,
|
||||
queryParams,
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("외주출고 삭제", { companyCode, id });
|
||||
return res.json({ success: true, message: "삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("외주출고 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외주출고번호 자동생성
|
||||
* GET /api/outsourcing-outbound/generate-number
|
||||
*/
|
||||
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
const yyyy = new Date().getFullYear();
|
||||
const prefix = `OSOUT-${yyyy}-`;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT outbound_number FROM outbound_mng
|
||||
WHERE company_code = $1 AND outbound_number LIKE $2
|
||||
ORDER BY outbound_number DESC LIMIT 1`,
|
||||
[companyCode, `${prefix}%`],
|
||||
);
|
||||
|
||||
let seq = 1;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNo = result.rows[0].outbound_number;
|
||||
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1;
|
||||
}
|
||||
|
||||
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
|
||||
return res.json({ success: true, data: newNumber });
|
||||
} catch (error: any) {
|
||||
logger.error("외주출고번호 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 창고 목록 (outbound 컨트롤러와 공유)
|
||||
*/
|
||||
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const condition = companyCode === "*" ? "" : `WHERE company_code = $1`;
|
||||
const params = companyCode === "*" ? [] : [companyCode];
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info ${condition} ORDER BY warehouse_name`,
|
||||
params,
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ export async function getPkgUnitItems(
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit
|
||||
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit, ii.inventory_unit, ii.material
|
||||
FROM pkg_unit_item pui
|
||||
LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code
|
||||
WHERE pui.pkg_code=$1 AND pui.company_code=$2
|
||||
@@ -228,10 +228,11 @@ export async function deletePkgUnitItem(
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
const query = companyCode === "*"
|
||||
? `DELETE FROM pkg_unit_item WHERE id=$1 RETURNING id`
|
||||
: `DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`;
|
||||
const params = companyCode === "*" ? [id] : [id, companyCode];
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
@@ -471,10 +472,11 @@ export async function deleteLoadingUnitPkg(
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
const query = companyCode === "*"
|
||||
? `DELETE FROM loading_unit_pkg WHERE id=$1 RETURNING id`
|
||||
: `DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`;
|
||||
const params = companyCode === "*" ? [id] : [id, companyCode];
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
@@ -530,7 +532,7 @@ export async function getItemsByDivision(
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size, material, unit, division
|
||||
`SELECT id, item_number, item_name, size, material, unit, inventory_unit, division
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name`,
|
||||
@@ -583,7 +585,7 @@ export async function getGeneralItems(
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size AS spec, material, unit, division
|
||||
`SELECT id, item_number, item_name, size AS spec, material, unit, inventory_unit, division
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name
|
||||
|
||||
@@ -154,10 +154,13 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { processCode } = req.params;
|
||||
|
||||
// equipment_code 컬럼에 코드(legacy) 또는 id(신규)가 들어올 수 있어 두 경우 모두 매칭
|
||||
const result = await pool.query(
|
||||
`SELECT pe.*, em.equipment_name
|
||||
FROM process_equipment pe
|
||||
LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code
|
||||
LEFT JOIN equipment_mng em
|
||||
ON pe.company_code = em.company_code
|
||||
AND (pe.equipment_code = em.equipment_code OR pe.equipment_code = em.id)
|
||||
WHERE pe.process_code = $1 AND pe.company_code = $2
|
||||
ORDER BY pe.equipment_code`,
|
||||
[processCode, companyCode]
|
||||
@@ -382,7 +385,38 @@ export async function getRoutingDetails(req: AuthenticatedRequest, res: Response
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
const rows = result.rows;
|
||||
const detailIds = rows.map((r: any) => r.id).filter(Boolean);
|
||||
let idsByDetail: Record<string, string[]> = {};
|
||||
let codesByDetail: Record<string, string[]> = {};
|
||||
if (detailIds.length > 0) {
|
||||
const mapRes = await pool.query(
|
||||
`SELECT irs.routing_detail_id, irs.subcontractor_id, sm.subcontractor_code
|
||||
FROM item_routing_subcontractor irs
|
||||
LEFT JOIN subcontractor_mng sm ON irs.subcontractor_id = sm.id
|
||||
WHERE irs.routing_detail_id = ANY($1::varchar[])
|
||||
ORDER BY irs.seq_order`,
|
||||
[detailIds]
|
||||
);
|
||||
for (const m of mapRes.rows) {
|
||||
const key = String(m.routing_detail_id);
|
||||
(idsByDetail[key] ||= []).push(m.subcontractor_id);
|
||||
if (m.subcontractor_code) (codesByDetail[key] ||= []).push(m.subcontractor_code);
|
||||
}
|
||||
}
|
||||
const enriched = rows.map((r: any) => {
|
||||
const ids = idsByDetail[String(r.id)] || [];
|
||||
const codes = codesByDetail[String(r.id)] || [];
|
||||
// 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼(code)에 값이 있으면 code 배열로 반환
|
||||
const legacyCodes = ids.length === 0 && r.outsource_supplier ? [r.outsource_supplier] : codes;
|
||||
return {
|
||||
...r,
|
||||
outsource_supplier_ids: ids,
|
||||
outsource_supplier_list: legacyCodes, // 하위호환 별칭 (code 배열)
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: enriched });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 상세 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
@@ -400,6 +434,15 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 기존 상세의 외주업체 매핑을 먼저 제거
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_subcontractor
|
||||
WHERE routing_detail_id IN (
|
||||
SELECT id FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2
|
||||
)`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
// 기존 상세 삭제 후 재입력
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
|
||||
@@ -407,11 +450,38 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
|
||||
);
|
||||
|
||||
for (const d of details) {
|
||||
await client.query(
|
||||
const supplierIds: string[] = Array.isArray(d.outsource_supplier_ids)
|
||||
? d.outsource_supplier_ids.filter((s: any) => typeof s === "string" && s.trim() !== "")
|
||||
: [];
|
||||
|
||||
// legacy code 해석: 첫 번째 subcontractor_id → subcontractor_code 조회
|
||||
let legacyCode = "";
|
||||
if (supplierIds.length > 0) {
|
||||
const codeRes = await client.query(
|
||||
`SELECT subcontractor_code FROM subcontractor_mng WHERE id=$1 LIMIT 1`,
|
||||
[supplierIds[0]]
|
||||
);
|
||||
legacyCode = codeRes.rows[0]?.subcontractor_code || "";
|
||||
} else if (d.outsource_supplier) {
|
||||
// 프론트가 아직 id 없이 code만 보낸 경우(레거시 호환)
|
||||
legacyCode = d.outsource_supplier;
|
||||
}
|
||||
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.outsource_supplier || "", writer]
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id`,
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, writer]
|
||||
);
|
||||
const newDetailId = insertRes.rows[0].id;
|
||||
|
||||
for (let i = 0; i < supplierIds.length; i++) {
|
||||
await client.query(
|
||||
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_id, seq_order)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4)`,
|
||||
[companyCode, newDetailId, supplierIds[i], i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import type { AuthenticatedRequest } from "../types/auth";
|
||||
import { adjustInventory } from "../utils/inventoryUtils";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환)
|
||||
@@ -472,7 +473,46 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 헤더 업데이트 (inbound_mng) — 헤더 레벨 필드만
|
||||
// 변경 전 값 조회 (헤더)
|
||||
const oldHeaderRes = await client.query(
|
||||
`SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode],
|
||||
);
|
||||
if (oldHeaderRes.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
const oldHeader = oldHeaderRes.rows[0];
|
||||
|
||||
// 변경 전 값 조회 (디테일, 있을 경우)
|
||||
let oldDetail: any = null;
|
||||
if (detail_id) {
|
||||
const oldDetailRes = await client.query(
|
||||
`SELECT * FROM inbound_detail WHERE id = $1 AND company_code = $2`,
|
||||
[detail_id, companyCode],
|
||||
);
|
||||
oldDetail = oldDetailRes.rows[0] || null;
|
||||
}
|
||||
|
||||
const oldQty =
|
||||
Number(oldDetail?.inbound_qty ?? oldHeader.inbound_qty) || 0;
|
||||
const oldWhCode = oldHeader.warehouse_code || null;
|
||||
const oldLocCode = oldHeader.location_code || null;
|
||||
const itemCode = oldDetail?.item_number || oldHeader.item_number || null;
|
||||
const inboundNumber = oldHeader.inbound_number;
|
||||
|
||||
const newQty =
|
||||
inbound_qty !== undefined && inbound_qty !== null
|
||||
? Number(inbound_qty)
|
||||
: oldQty;
|
||||
const newWhCode =
|
||||
warehouse_code !== undefined ? warehouse_code : oldWhCode;
|
||||
const newLocCode =
|
||||
location_code !== undefined ? location_code : oldLocCode;
|
||||
|
||||
// 입고 레코드 업데이트 (헤더 + 품목 필드 모두)
|
||||
const headerResult = await client.query(
|
||||
`UPDATE inbound_mng SET
|
||||
inbound_date = COALESCE($1::date, inbound_date),
|
||||
@@ -482,6 +522,9 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
inspector = COALESCE($5, inspector),
|
||||
manager = COALESCE($6, manager),
|
||||
memo = COALESCE($7, memo),
|
||||
inbound_qty = COALESCE($11::numeric, inbound_qty),
|
||||
unit_price = COALESCE($12::numeric, unit_price),
|
||||
total_amount = COALESCE($13::numeric, total_amount),
|
||||
updated_date = NOW(),
|
||||
updated_by = $8
|
||||
WHERE id = $9 AND company_code = $10
|
||||
@@ -497,16 +540,12 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
userId,
|
||||
id,
|
||||
companyCode,
|
||||
inbound_qty || null,
|
||||
unit_price || null,
|
||||
total_amount || null,
|
||||
],
|
||||
);
|
||||
|
||||
if (headerResult.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
// 디테일 업데이트 (inbound_detail) — detail_id가 있으면 디테일 레벨 필드 업데이트
|
||||
let detailRow = null;
|
||||
if (detail_id) {
|
||||
@@ -557,9 +596,67 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
);
|
||||
}
|
||||
|
||||
// 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시
|
||||
const qtyChanged = newQty !== oldQty;
|
||||
const whChanged =
|
||||
(newWhCode || "") !== (oldWhCode || "") ||
|
||||
(newLocCode || "") !== (oldLocCode || "");
|
||||
|
||||
if (itemCode && (qtyChanged || whChanged)) {
|
||||
if (whChanged) {
|
||||
if (oldQty > 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: oldWhCode,
|
||||
locCode: oldLocCode,
|
||||
delta: -oldQty,
|
||||
transactionType: "입고취소",
|
||||
remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}`,
|
||||
});
|
||||
}
|
||||
if (newQty > 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: newWhCode,
|
||||
locCode: newLocCode,
|
||||
delta: newQty,
|
||||
transactionType: "입고수정",
|
||||
remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}, 수량 ${oldQty}→${newQty}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const delta = newQty - oldQty;
|
||||
if (delta !== 0) {
|
||||
await adjustInventory(client, {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode: newWhCode,
|
||||
locCode: newLocCode,
|
||||
delta,
|
||||
transactionType: "입고수정",
|
||||
remark: `입고수정 (${inboundNumber}) 수량 ${oldQty}→${newQty}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("입고 수정", { companyCode, userId, id, detail_id });
|
||||
logger.info("입고 수정", {
|
||||
companyCode,
|
||||
userId,
|
||||
id,
|
||||
detail_id,
|
||||
oldQty,
|
||||
newQty,
|
||||
oldWhCode,
|
||||
newWhCode,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
||||
93
backend-node/src/controllers/reportCellValueController.ts
Normal file
93
backend-node/src/controllers/reportCellValueController.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 리포트 셀 커스텀 입력값 컨트롤러
|
||||
*
|
||||
* 리포트 디자이너에서 cellType="input"으로 지정한 셀에 대해
|
||||
* 각 대상 레코드(quote 등)별로 사용자가 입력한 값을 관리
|
||||
*/
|
||||
|
||||
import type { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import type { AuthenticatedRequest } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 목록 조회: 특정 리포트 + 타겟에 대한 모든 셀 값
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { report_id, target_type, target_id } = req.query;
|
||||
|
||||
if (!report_id || !target_type || !target_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "report_id, target_type, target_id는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT id, report_id, target_type, target_id, component_id, cell_id, value
|
||||
FROM report_cell_values
|
||||
WHERE company_code = $1 AND report_id = $2 AND target_type = $3 AND target_id = $4`,
|
||||
[companyCode, report_id, target_type, target_id],
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("리포트 셀 값 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// UPSERT 단건: 같은 (report_id, target_type, target_id, component_id, cell_id)면 UPDATE, 아니면 INSERT
|
||||
export async function upsert(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { report_id, target_type, target_id, component_id, cell_id, value } =
|
||||
req.body;
|
||||
|
||||
if (!report_id || !target_type || !target_id || !component_id || !cell_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드 누락",
|
||||
});
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// value가 빈 문자열이면 DELETE (오버라이드 해제)
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
await pool.query(
|
||||
`DELETE FROM report_cell_values
|
||||
WHERE company_code = $1 AND report_id = $2 AND target_type = $3
|
||||
AND target_id = $4 AND component_id = $5 AND cell_id = $6`,
|
||||
[companyCode, report_id, target_type, target_id, component_id, cell_id],
|
||||
);
|
||||
return res.json({ success: true, data: null });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO report_cell_values
|
||||
(id, company_code, report_id, target_type, target_id, component_id, cell_id, value, created_by, updated_by)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
ON CONFLICT (company_code, report_id, target_type, target_id, component_id, cell_id)
|
||||
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP, updated_by = EXCLUDED.updated_by
|
||||
RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
report_id,
|
||||
target_type,
|
||||
target_id,
|
||||
component_id,
|
||||
cell_id,
|
||||
value,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("리포트 셀 값 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
|
||||
const includeInactive = req.query.includeInactive === "true";
|
||||
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
|
||||
const filterCompanyCode = req.query.filterCompanyCode as string | undefined;
|
||||
const topLevelOnly = req.query.topLevelOnly === "true";
|
||||
|
||||
// 최고관리자가 특정 회사 기준 필터링을 요청한 경우 해당 회사 코드 사용
|
||||
const effectiveCompanyCode = (userCompanyCode === "*" && filterCompanyCode)
|
||||
@@ -86,7 +87,8 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
|
||||
columnName,
|
||||
effectiveCompanyCode,
|
||||
includeInactive,
|
||||
menuObjid
|
||||
menuObjid,
|
||||
topLevelOnly
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
||||
@@ -7,13 +7,19 @@ import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
|
||||
// 자동 마이그레이션: work_instruction_detail에 routing_version_id 컬럼 추가
|
||||
// 자동 마이그레이션: work_instruction_detail에 routing_version_id + 품목별 일정/설비/작업조/작업자 컬럼 추가
|
||||
let _migrationDone = false;
|
||||
async function ensureDetailRoutingColumn() {
|
||||
if (_migrationDone) return;
|
||||
try {
|
||||
const pool = getPool();
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS routing_version_id VARCHAR(500)");
|
||||
// 품목별 일정/설비/작업조/작업자 컬럼 (옵션 A — 다중선택 지원)
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS start_date VARCHAR(500)");
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS end_date VARCHAR(500)");
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS equipment_ids VARCHAR(1000)");
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS work_teams VARCHAR(200)");
|
||||
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS workers VARCHAR(1000)");
|
||||
_migrationDone = true;
|
||||
} catch { /* 이미 존재하거나 권한 문제 시 무시 */ }
|
||||
}
|
||||
@@ -23,7 +29,12 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
await ensureDetailRoutingColumn();
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
|
||||
const { dateFrom, dateTo, status, progressStatus, keyword, page, pageSize } = req.query;
|
||||
|
||||
// 페이지네이션 파라미터 파싱 (page 없으면 전체 반환 — 하위호환)
|
||||
const pageNum = page ? Math.max(1, parseInt(page as string, 10) || 1) : null;
|
||||
const sizeNum = pageSize ? Math.max(1, Math.min(1000, parseInt(pageSize as string, 10) || 20)) : null;
|
||||
const paginated = pageNum !== null && sizeNum !== null;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
@@ -54,14 +65,115 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
params.push(progressStatus);
|
||||
idx++;
|
||||
}
|
||||
// keyword 검색: wi 자체 필드 + detail.item_number 존재 여부로 EXISTS
|
||||
if (keyword) {
|
||||
conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`);
|
||||
conditions.push(`(
|
||||
wi.work_instruction_no ILIKE $${idx}
|
||||
OR wi.worker ILIKE $${idx}
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM work_instruction_detail dd
|
||||
LEFT JOIN item_info ii ON ii.item_number = dd.item_number AND ii.company_code = wi.company_code
|
||||
WHERE dd.work_instruction_id = wi.id
|
||||
AND (dd.item_number ILIKE $${idx} OR COALESCE(ii.item_name,'') ILIKE $${idx})
|
||||
)
|
||||
)`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 페이지네이션 모드: WI 단위로 페이지 잘라낸 뒤 detail과 JOIN
|
||||
if (paginated) {
|
||||
// 1) 총 WI 개수 카운트
|
||||
const countSql = `
|
||||
SELECT COUNT(*)::int AS cnt
|
||||
FROM work_instruction wi
|
||||
${whereClause}
|
||||
`;
|
||||
const countRes = await pool.query(countSql, params);
|
||||
const totalCount = countRes.rows[0]?.cnt ?? 0;
|
||||
|
||||
// 2) 현재 페이지 WI id 목록
|
||||
const offset = (pageNum! - 1) * sizeNum!;
|
||||
const pageSql = `
|
||||
SELECT wi.id
|
||||
FROM work_instruction wi
|
||||
${whereClause}
|
||||
ORDER BY wi.created_date DESC, wi.id DESC
|
||||
LIMIT ${sizeNum} OFFSET ${offset}
|
||||
`;
|
||||
const pageRes = await pool.query(pageSql, params);
|
||||
const wiIds = pageRes.rows.map((r) => r.id);
|
||||
|
||||
if (wiIds.length === 0) {
|
||||
return res.json({ success: true, data: [], totalCount, page: pageNum, pageSize: sizeNum });
|
||||
}
|
||||
|
||||
// 3) 해당 WI들의 detail + 품목/설비/라우팅 JOIN
|
||||
const dataSql = `
|
||||
SELECT
|
||||
wi.id AS wi_id,
|
||||
wi.work_instruction_no,
|
||||
wi.status,
|
||||
wi.progress_status,
|
||||
wi.qty AS total_qty,
|
||||
wi.completed_qty,
|
||||
wi.start_date,
|
||||
wi.end_date,
|
||||
wi.equipment_id,
|
||||
wi.work_team,
|
||||
wi.worker,
|
||||
wi.remark AS wi_remark,
|
||||
wi.created_date,
|
||||
d.id AS detail_id,
|
||||
d.item_number,
|
||||
d.qty AS detail_qty,
|
||||
d.remark AS detail_remark,
|
||||
d.part_code,
|
||||
d.source_table,
|
||||
d.source_id,
|
||||
d.routing_version_id AS detail_routing_version_id,
|
||||
d.start_date AS detail_start_date,
|
||||
d.end_date AS detail_end_date,
|
||||
d.equipment_ids AS detail_equipment_ids,
|
||||
d.work_teams AS detail_work_teams,
|
||||
d.workers AS detail_workers,
|
||||
COALESCE(itm.item_name, '') AS item_name,
|
||||
COALESCE(itm.type, '') AS item_type,
|
||||
COALESCE(itm.size, '') AS item_spec,
|
||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||
COALESCE(e.equipment_code, '') AS equipment_code,
|
||||
wi.routing AS routing_version_id,
|
||||
COALESCE(rv.version_name, '') AS routing_name,
|
||||
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
|
||||
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
||||
FROM work_instruction wi
|
||||
INNER JOIN work_instruction_detail d
|
||||
ON d.work_instruction_id = wi.id
|
||||
LEFT JOIN item_info itm
|
||||
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
|
||||
LEFT JOIN equipment_mng e
|
||||
ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
||||
LEFT JOIN item_routing_version rv
|
||||
ON wi.routing = rv.id AND rv.company_code = wi.company_code
|
||||
WHERE wi.id = ANY($1::varchar[])
|
||||
ORDER BY wi.created_date DESC, wi.id DESC, d.created_date ASC
|
||||
`;
|
||||
const dataRes = await pool.query(dataSql, [wiIds]);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: dataRes.rows,
|
||||
totalCount,
|
||||
page: pageNum,
|
||||
pageSize: sizeNum,
|
||||
});
|
||||
}
|
||||
|
||||
// 비페이지 모드 (하위호환): 기존 방식 유지, LATERAL만 LEFT JOIN으로 교체
|
||||
const query = `
|
||||
SELECT
|
||||
wi.id AS wi_id,
|
||||
@@ -85,6 +197,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
d.source_table,
|
||||
d.source_id,
|
||||
d.routing_version_id AS detail_routing_version_id,
|
||||
d.start_date AS detail_start_date,
|
||||
d.end_date AS detail_end_date,
|
||||
d.equipment_ids AS detail_equipment_ids,
|
||||
d.work_teams AS detail_work_teams,
|
||||
d.workers AS detail_workers,
|
||||
COALESCE(itm.item_name, '') AS item_name,
|
||||
COALESCE(itm.type, '') AS item_type,
|
||||
COALESCE(itm.size, '') AS item_spec,
|
||||
@@ -97,17 +214,14 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
FROM work_instruction wi
|
||||
INNER JOIN work_instruction_detail d
|
||||
ON d.work_instruction_id = wi.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name, size, type FROM item_info
|
||||
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
|
||||
) itm ON true
|
||||
LEFT JOIN item_info itm
|
||||
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
|
||||
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
||||
LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code
|
||||
${whereClause}
|
||||
ORDER BY wi.created_date DESC, d.created_date ASC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
@@ -195,8 +309,25 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
if (!firstRouting && itemRouting) firstRouting = itemRouting;
|
||||
totalQty += Number(item.qty || 0);
|
||||
await client.query(
|
||||
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11)`,
|
||||
[companyCode, wiNo, wiId, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", itemRouting, userId]
|
||||
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,start_date,end_date,equipment_ids,work_teams,workers,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),$16)`,
|
||||
[
|
||||
companyCode,
|
||||
wiNo,
|
||||
wiId,
|
||||
item.itemNumber||item.itemCode||"",
|
||||
item.qty||"0",
|
||||
item.remark||"",
|
||||
item.sourceTable||"",
|
||||
item.sourceId||"",
|
||||
item.partCode||item.itemNumber||item.itemCode||"",
|
||||
itemRouting,
|
||||
item.startDate||"",
|
||||
item.endDate||"",
|
||||
item.equipmentIds||"",
|
||||
item.workTeams||"",
|
||||
item.workers||"",
|
||||
userId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -296,7 +427,30 @@ export async function getProductionPlanSource(req: AuthenticatedRequest, res: Re
|
||||
const pool = getPool();
|
||||
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params);
|
||||
params.push(pageSize, offset);
|
||||
const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
|
||||
// work_instruction_detail에서 해당 계획에 이미 내린 작업지시 수량 합계 → applied_qty, remain_qty
|
||||
const rows = await pool.query(
|
||||
`SELECT p.id, p.plan_no, p.item_code,
|
||||
COALESCE(p.item_name,'') AS item_name,
|
||||
COALESCE(p.plan_qty,0) AS plan_qty,
|
||||
p.start_date, p.end_date, p.status,
|
||||
COALESCE(p.equipment_name,'') AS equipment_name,
|
||||
COALESCE(wi.applied_qty, 0) AS applied_qty,
|
||||
(COALESCE(CAST(NULLIF(p.plan_qty::text, '') AS numeric), 0)
|
||||
- COALESCE(wi.applied_qty, 0)) AS remain_qty
|
||||
FROM production_plan_mng p
|
||||
LEFT JOIN (
|
||||
SELECT source_id,
|
||||
SUM(COALESCE(CAST(NULLIF(qty, '') AS numeric), 0)) AS applied_qty
|
||||
FROM work_instruction_detail
|
||||
WHERE source_table = 'production_plan_mng'
|
||||
AND company_code = $1
|
||||
GROUP BY source_id
|
||||
) wi ON wi.source_id = p.id::text
|
||||
WHERE ${w}
|
||||
ORDER BY p.created_date DESC
|
||||
LIMIT $${idx} OFFSET $${idx+1}`,
|
||||
params,
|
||||
);
|
||||
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
|
||||
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
toggleMenuStatus, // 메뉴 상태 토글
|
||||
copyMenu, // 메뉴 복사
|
||||
getUserList,
|
||||
getUserNameMap, // 사용자 ID→이름 맵 (경량)
|
||||
getUserInfo, // 사용자 상세 조회
|
||||
getUserHistory, // 사용자 변경이력 조회
|
||||
changeUserStatus, // 사용자 상태 변경
|
||||
@@ -70,6 +71,7 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
|
||||
|
||||
// 사용자 관리 API
|
||||
router.get("/users", getUserList);
|
||||
router.get("/users/name-map", getUserNameMap); // 사용자 ID→이름 매핑 (경량)
|
||||
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
||||
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
||||
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
|
||||
|
||||
30
backend-node/src/routes/outsourcingOutboundRoutes.ts
Normal file
30
backend-node/src/routes/outsourcingOutboundRoutes.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as ctrl from "../controllers/outsourcingOutboundController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 외주출고 대상 자동 조회
|
||||
router.get("/candidates", ctrl.getCandidates);
|
||||
|
||||
// 외주출고 목록 조회
|
||||
router.get("/list", ctrl.getList);
|
||||
|
||||
// 외주출고번호 자동생성
|
||||
router.get("/generate-number", ctrl.generateNumber);
|
||||
|
||||
// 창고 목록
|
||||
router.get("/warehouses", ctrl.getWarehouses);
|
||||
|
||||
// 외주출고 등록
|
||||
router.post("/", ctrl.create);
|
||||
|
||||
// 외주출고 수정
|
||||
router.put("/:id", ctrl.update);
|
||||
|
||||
// 외주출고 삭제
|
||||
router.delete("/:id", ctrl.deleteOutbound);
|
||||
|
||||
export default router;
|
||||
12
backend-node/src/routes/reportCellValueRoutes.ts
Normal file
12
backend-node/src/routes/reportCellValueRoutes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as controller from "../controllers/reportCellValueController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get("/", controller.getList);
|
||||
router.post("/", controller.upsert);
|
||||
|
||||
export default router;
|
||||
@@ -60,8 +60,9 @@ export async function getBomHeader(bomId: string, tableName?: string) {
|
||||
const sql = `
|
||||
SELECT b.*,
|
||||
i.item_name, i.item_number, i.division as item_type,
|
||||
COALESCE(b.unit, i.unit) as unit,
|
||||
COALESCE(NULLIF(b.unit, ''), NULLIF(i.unit, ''), NULLIF(i.inventory_unit, '')) as unit,
|
||||
i.unit as item_unit,
|
||||
i.inventory_unit as item_inventory_unit,
|
||||
i.division, i.size, i.material
|
||||
FROM ${table} b
|
||||
LEFT JOIN item_info i ON b.item_id = i.id
|
||||
|
||||
@@ -223,13 +223,14 @@ class CategoryTreeService {
|
||||
|
||||
const query = `
|
||||
INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
value_id, table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code, created_by, updated_by
|
||||
) VALUES (
|
||||
(SELECT COALESCE(MAX(value_id), 0) + 1 FROM category_values),
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15
|
||||
)
|
||||
RETURNING
|
||||
RETURNING
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
|
||||
@@ -694,13 +694,16 @@ export async function mergeSchedules(
|
||||
[companyCode, ...scheduleIds]
|
||||
);
|
||||
|
||||
// 병합된 스케줄 생성
|
||||
// 병합된 스케줄 생성 (PP-YYYYMMDD-NNNN 형식)
|
||||
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
`SELECT COUNT(*) + 1 AS next_no
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND plan_no LIKE $2`,
|
||||
[companyCode, `PP-${todayStr}-%`]
|
||||
);
|
||||
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
|
||||
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
@@ -1017,13 +1020,16 @@ export async function splitSchedule(
|
||||
[originalQty - splitQty, splitBy, planId, companyCode]
|
||||
);
|
||||
|
||||
// 분할된 새 계획 생성
|
||||
// 분할된 새 계획 생성 (PP-YYYYMMDD-NNNN 형식)
|
||||
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
`SELECT COUNT(*) + 1 AS next_no
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND plan_no LIKE $2`,
|
||||
[companyCode, `PP-${todayStr}-%`]
|
||||
);
|
||||
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
|
||||
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
|
||||
@@ -884,18 +884,23 @@ export class ReportService {
|
||||
menuObjid: number,
|
||||
companyCode: string
|
||||
): Promise<{ items: ReportMaster[]; total: number }> {
|
||||
// 매핑 없는 리포트(글로벌)는 어느 메뉴에서나 보이고,
|
||||
// 매핑 있는 리포트는 해당 menu_objid에 매핑된 경우에만 보임.
|
||||
const companyFilter = companyCode !== "*" ? " AND rm.company_code = $2" : "";
|
||||
const params = companyCode !== "*" ? [menuObjid, companyCode] : [menuObjid];
|
||||
|
||||
const items = await query<ReportMaster>(
|
||||
`SELECT rm.report_id, rm.report_name_kor, rm.report_name_eng,
|
||||
`SELECT DISTINCT rm.report_id, rm.report_name_kor, rm.report_name_eng,
|
||||
rm.template_id, rt.template_name_kor AS template_name,
|
||||
rm.report_type, rm.company_code, rm.description, rm.use_yn,
|
||||
rm.created_at, rm.created_by, rm.updated_at, rm.updated_by
|
||||
FROM report_master rm
|
||||
JOIN report_menu_mapping rmm ON rm.report_id = rmm.report_id
|
||||
LEFT JOIN report_template rt ON rm.template_id = rt.template_id
|
||||
WHERE rmm.menu_objid = $1 AND rm.use_yn = 'Y'${companyFilter}
|
||||
WHERE rm.use_yn = 'Y'${companyFilter}
|
||||
AND (
|
||||
NOT EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id)
|
||||
OR EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id AND menu_objid = $1)
|
||||
)
|
||||
ORDER BY rm.report_name_kor ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
@@ -167,7 +167,8 @@ class TableCategoryValueService {
|
||||
columnName: string,
|
||||
companyCode: string,
|
||||
includeInactive: boolean = false,
|
||||
menuObjid?: number
|
||||
menuObjid?: number,
|
||||
topLevelOnly: boolean = false
|
||||
): Promise<TableCategoryValue[]> {
|
||||
try {
|
||||
logger.info("카테고리 값 목록 조회 (메뉴 스코프)", {
|
||||
@@ -235,6 +236,10 @@ class TableCategoryValueService {
|
||||
query += ` AND is_active = true`;
|
||||
}
|
||||
|
||||
if (topLevelOnly) {
|
||||
query += ` AND (depth = 1 OR depth IS NULL OR parent_value_id IS NULL)`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY value_order, value_label`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
130
backend-node/src/utils/inventoryUtils.ts
Normal file
130
backend-node/src/utils/inventoryUtils.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { PoolClient } from "pg";
|
||||
|
||||
export interface AdjustInventoryParams {
|
||||
companyCode: string;
|
||||
userId: string;
|
||||
itemCode: string;
|
||||
whCode: string | null;
|
||||
locCode: string | null;
|
||||
delta: number;
|
||||
transactionType: string;
|
||||
remark: string;
|
||||
validateStockEnough?: boolean;
|
||||
}
|
||||
|
||||
export async function adjustInventory(
|
||||
client: PoolClient,
|
||||
params: AdjustInventoryParams,
|
||||
): Promise<void> {
|
||||
const {
|
||||
companyCode,
|
||||
userId,
|
||||
itemCode,
|
||||
whCode,
|
||||
locCode,
|
||||
delta,
|
||||
transactionType,
|
||||
remark,
|
||||
validateStockEnough,
|
||||
} = params;
|
||||
|
||||
if (!itemCode || delta === 0) return;
|
||||
|
||||
if (validateStockEnough && delta < 0) {
|
||||
const stockRes = await client.query(
|
||||
`SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS cur
|
||||
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, itemCode, whCode || "", locCode || ""],
|
||||
);
|
||||
const cur = parseFloat(stockRes.rows[0]?.cur || "0");
|
||||
if (cur + delta < 0) {
|
||||
throw new Error(
|
||||
`재고 부족: 품목 ${itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${cur}, 차감 요청 ${-delta}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await client.query(
|
||||
`SELECT id 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, itemCode, whCode || "", locCode || ""],
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
if (delta >= 0) {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
|
||||
last_in_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2`,
|
||||
[delta, existing.rows[0].id],
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1, 0) AS text),
|
||||
last_out_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2`,
|
||||
[delta, existing.rows[0].id],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const initQty = Math.max(delta, 0);
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
current_qty, safety_qty, last_in_date, last_out_date,
|
||||
created_date, updated_date, writer
|
||||
) VALUES (
|
||||
gen_random_uuid()::text, $1, $2, $3, $4,
|
||||
$5, '0',
|
||||
${delta > 0 ? "NOW()" : "NULL"},
|
||||
${delta < 0 ? "NOW()" : "NULL"},
|
||||
NOW(), NOW(), $6
|
||||
)`,
|
||||
[companyCode, itemCode, whCode, locCode, String(initQty), userId],
|
||||
);
|
||||
}
|
||||
|
||||
const afterRes = await client.query(
|
||||
`SELECT 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, itemCode, whCode || "", locCode || ""],
|
||||
);
|
||||
const afterQty = afterRes.rows[0]?.current_qty || "0";
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||
writer, created_date
|
||||
) VALUES (
|
||||
gen_random_uuid()::text, $1, $2, $3, $4,
|
||||
$5, NOW(), $6, $7, $8,
|
||||
$9, NOW()
|
||||
)`,
|
||||
[
|
||||
companyCode,
|
||||
itemCode,
|
||||
whCode,
|
||||
locCode,
|
||||
transactionType,
|
||||
(delta > 0 ? "+" : "") + String(delta),
|
||||
afterQty,
|
||||
remark,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user