Re-apply mhkim work lost in jskim-node conflict resolution
Backend:
- outboundController.ts: restore sales_order_id/shipment_plan_id/item_info_id columns in outbound_mng INSERT; restore getProductionResults endpoint
- popProductionController.ts: restore transactionPackagingService import (ensureLoadingInstance/insertPackagingRows); restore material auto-input + inventory_stock deduction before WIP trigger; restore autoCompleteProcess/savePackaging/getProcessPackaging endpoints
- workInstructionController.ts: restore getProcessResults function for production-result right panel
- workInstructionRoutes.ts: restore GET /:wiId/process-results route
Frontend:
- COMPANY_7/production/work-instruction/page.tsx: restore Lock icon, WorkRow detailId/locked fields, items mapping detailId, locked column UI with lock icon and disabled cells
- COMPANY_8/10/16/28/29/production/work-instruction/page.tsx: restore SelectedItem baseQty/splitMode fields, calcBatchCount/splitQty helpers, expandedItems batch split logic in finalizeRegistration payload (keeps jskim infos field)
- COMPANY_8/logistics/inbound-outbound/page.tsx: restore autoFilter:true (company scope) for user_info writer lookup — replaces jskim autoFilter:{enabled:false} which violated multitenancy policy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -144,7 +144,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
outbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
outbound_status, manager_id, memo,
|
||||
source_table, source_id,
|
||||
source_table, source_id, sales_order_id, shipment_plan_id, item_info_id,
|
||||
destination_code, delivery_destination, delivery_address,
|
||||
created_date, created_by, writer, status
|
||||
) VALUES (
|
||||
@@ -154,9 +154,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
$13, $14, $15,
|
||||
$16, $17, $18,
|
||||
$19, $20, $21,
|
||||
$22, $23,
|
||||
$24, $25, $26,
|
||||
NOW(), $27, $27, '출고'
|
||||
$22, $23, $24, $25, $26,
|
||||
$27, $28, $29,
|
||||
NOW(), $30, $30, '출고'
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
@@ -180,8 +180,11 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
item.outbound_status || "대기",
|
||||
manager_id || item.manager_id || null,
|
||||
memo || item.memo || null,
|
||||
item.source_table || null,
|
||||
item.source_type || item.source_table || null,
|
||||
item.source_id || null,
|
||||
item.sales_order_id || null,
|
||||
item.shipment_plan_id || null,
|
||||
item.item_info_id || null,
|
||||
item.destination_code || null,
|
||||
item.delivery_destination || null,
|
||||
item.delivery_address || null,
|
||||
@@ -853,3 +856,149 @@ export async function getLocations(req: AuthenticatedRequest, res: Response) {
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
export async function getProductionResults(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { processCode, keyword, pageSize } = req.query;
|
||||
|
||||
const limit = Math.min(500, Math.max(1, Number(pageSize) || 100));
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
let processCondition = "";
|
||||
if (processCode) {
|
||||
processCondition = `AND wop.process_code = $${paramIdx}`;
|
||||
params.push(processCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
let keywordCondition = "";
|
||||
if (keyword) {
|
||||
keywordCondition = `AND (wi.work_instruction_no ILIKE $${paramIdx} OR COALESCE(ii.item_name, '') ILIKE $${paramIdx} OR COALESCE(ii.item_number, '') ILIKE $${paramIdx})`;
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
const dataResult = await pool.query(
|
||||
`SELECT
|
||||
wr.id,
|
||||
wop.id AS wop_id,
|
||||
wop.wo_id,
|
||||
wi.work_instruction_no,
|
||||
wi.start_date AS order_date,
|
||||
COALESCE(CAST(NULLIF(wi.qty, '') AS numeric), 0) AS instruction_qty,
|
||||
wop.process_code,
|
||||
wop.process_name,
|
||||
wop.seq_no,
|
||||
COALESCE(ii.item_number, wi.item_id) AS item_code,
|
||||
COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name,
|
||||
COALESCE(ii.size, '') AS spec,
|
||||
COALESCE(ii.material, '') AS material,
|
||||
NULLIF(ii.unit, '') AS unit,
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) AS good_qty,
|
||||
COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS concession_qty,
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
||||
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS order_qty,
|
||||
COALESCE(ship.shipped_qty, 0) AS shipped_qty,
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
||||
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
||||
- COALESCE(ship.shipped_qty, 0) AS remain_qty,
|
||||
'work_order_process_result' AS source_table,
|
||||
wr.result_status,
|
||||
COALESCE(ii.image, NULL) AS image,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM item_inspection_info iii
|
||||
WHERE iii.company_code = wop.company_code
|
||||
AND COALESCE(iii.is_active, 'Y') IN ('Y', '사용')
|
||||
AND iii.item_code = COALESCE(ii.item_number, wi.item_id)
|
||||
) THEN 'self' ELSE NULL END AS inspection_type,
|
||||
tp_agg.packages,
|
||||
tl.loading_code,
|
||||
tl.loading_name
|
||||
FROM work_order_process_result wr
|
||||
JOIN work_order_process wop
|
||||
ON wop.id = wr.wop_id
|
||||
AND wop.company_code = wr.company_code
|
||||
JOIN work_instruction wi
|
||||
ON wi.id = wop.wo_id
|
||||
AND wi.company_code = wop.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (id, company_code)
|
||||
id, item_number, item_name, size, material, unit, image, company_code
|
||||
FROM item_info
|
||||
ORDER BY id, company_code, created_date DESC
|
||||
) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code
|
||||
LEFT JOIN (
|
||||
SELECT source_id, company_code,
|
||||
SUM(COALESCE(CAST(NULLIF(outbound_qty::text, '') AS numeric), 0)) AS shipped_qty
|
||||
FROM outbound_mng
|
||||
WHERE source_table = 'work_order_process_result'
|
||||
AND company_code = $1
|
||||
AND source_id IS NOT NULL
|
||||
GROUP BY source_id, company_code
|
||||
) ship ON ship.source_id = wr.id AND ship.company_code = wr.company_code
|
||||
LEFT JOIN (
|
||||
SELECT packed.source_id, packed.company_code,
|
||||
JSON_AGG(JSON_BUILD_OBJECT(
|
||||
'pkg_code', packed.pkg_code,
|
||||
'pkg_name', COALESCE(pu.pkg_name, packed.pkg_code),
|
||||
'count', packed.cnt,
|
||||
'qty_per_unit', packed.qty_per_unit
|
||||
)) AS packages
|
||||
FROM (
|
||||
SELECT source_id, company_code, pkg_code,
|
||||
CAST(quantity AS numeric) AS qty_per_unit,
|
||||
COUNT(*)::int AS cnt
|
||||
FROM transaction_packaging
|
||||
WHERE company_code = $1
|
||||
AND source_type = 'work_order_process_result'
|
||||
GROUP BY source_id, company_code, pkg_code, quantity
|
||||
) packed
|
||||
LEFT JOIN pkg_unit pu
|
||||
ON pu.pkg_code = packed.pkg_code
|
||||
AND pu.company_code = packed.company_code
|
||||
GROUP BY packed.source_id, packed.company_code
|
||||
) tp_agg ON tp_agg.source_id = wr.id AND tp_agg.company_code = wr.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (source_doc_id, company_code)
|
||||
source_doc_id, company_code,
|
||||
loading_code, loading_name
|
||||
FROM transaction_loading
|
||||
WHERE company_code = $1
|
||||
AND source_type = 'work_order_process_result'
|
||||
ORDER BY source_doc_id, company_code, COALESCE(loading_seq, 1) ASC
|
||||
) tl ON tl.source_doc_id = wr.id AND tl.company_code = wr.company_code
|
||||
WHERE wr.company_code = $1
|
||||
AND CAST(wop.seq_no AS int) = (
|
||||
SELECT MAX(CAST(wop2.seq_no AS int))
|
||||
FROM work_order_process wop2
|
||||
WHERE wop2.wo_id = wop.wo_id
|
||||
AND wop2.company_code = wop.company_code
|
||||
)
|
||||
AND (
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
||||
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
||||
) > 0
|
||||
AND (
|
||||
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
|
||||
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
|
||||
- COALESCE(ship.shipped_qty, 0)
|
||||
) > 0
|
||||
${processCondition}
|
||||
${keywordCondition}
|
||||
ORDER BY wi.work_instruction_no, wr.created_date NULLS LAST
|
||||
LIMIT ${limit}`,
|
||||
params,
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: dataResult.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("생산출고 소스 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
onProcessMove,
|
||||
onResultSaved,
|
||||
} from "../services/wipStockService";
|
||||
import {
|
||||
ensureLoadingInstance,
|
||||
insertPackagingRows,
|
||||
} from "../services/transactionPackagingService";
|
||||
|
||||
/**
|
||||
* user_id → user_name(한글명) 조회 헬퍼 — wip_stock_history.manager_name 기록용.
|
||||
@@ -1304,6 +1308,7 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => {
|
||||
defect_qty,
|
||||
defect_detail,
|
||||
result_note,
|
||||
material_inputs,
|
||||
} = req.body;
|
||||
|
||||
// validation: BEGIN 이전에 처리
|
||||
@@ -1575,6 +1580,102 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await checkAndCompleteWorkInstruction(client, csWoId, companyCode, userId);
|
||||
}
|
||||
|
||||
// 자재 자동 투입 + 재고 차감 (이번 차수 생산수량 × 1개당 소요량)
|
||||
// material_inputs 가 있으면 process_work_result INSERT + inventory_stock 차감.
|
||||
// 재고 부족 시 ROLLBACK + 400 응답.
|
||||
if (Array.isArray(material_inputs) && material_inputs.length > 0) {
|
||||
for (const mi of material_inputs) {
|
||||
const childItemId = mi.child_item_id;
|
||||
const childItemCode = mi.child_item_code;
|
||||
const childItemName = mi.child_item_name || "";
|
||||
const inputQty = parseFloat(String(mi.input_qty));
|
||||
const unit = mi.unit || "";
|
||||
const bomDetailId = mi.bom_detail_id || null;
|
||||
const requiredQty = mi.required_qty != null ? String(mi.required_qty) : null;
|
||||
|
||||
if (!childItemId || !childItemCode || isNaN(inputQty) || inputQty <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 창고/로케이션 자동 선택 (기존 saveMaterialInput 패턴: 가장 최근 입고 우선)
|
||||
const autoStock = await client.query(
|
||||
`SELECT warehouse_code, location_code,
|
||||
COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS current_qty
|
||||
FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) > 0
|
||||
ORDER BY last_in_date DESC NULLS LAST LIMIT 1`,
|
||||
[companyCode, childItemCode],
|
||||
);
|
||||
|
||||
if (autoStock.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `자재 재고 부족: ${childItemName || childItemCode} (현재고 0)`,
|
||||
});
|
||||
}
|
||||
|
||||
const stockRow = autoStock.rows[0];
|
||||
const stockQty = parseFloat(String(stockRow.current_qty)) || 0;
|
||||
if (stockQty < inputQty) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `자재 재고 부족: ${childItemName || childItemCode} (현재고 ${stockQty}, 필요 ${inputQty})`,
|
||||
});
|
||||
}
|
||||
|
||||
const effectiveWh = stockRow.warehouse_code;
|
||||
const effectiveLoc = stockRow.location_code || effectiveWh;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO process_work_result (
|
||||
id, company_code, work_order_process_id,
|
||||
detail_type, detail_content, item_title,
|
||||
result_value, unit, is_passed, status,
|
||||
bom_detail_id, required_qty, warehouse_code, location_code,
|
||||
recorded_by, recorded_at, writer
|
||||
) VALUES (
|
||||
gen_random_uuid()::text, $1, $2,
|
||||
'material_input', $3, $4,
|
||||
$5, $6, 'Y', 'completed',
|
||||
$7, $8, $9, $10,
|
||||
$11, NOW()::text, $11
|
||||
)`,
|
||||
[
|
||||
companyCode,
|
||||
work_order_process_id,
|
||||
childItemCode,
|
||||
childItemName,
|
||||
String(inputQty),
|
||||
unit,
|
||||
bomDetailId,
|
||||
requiredQty,
|
||||
effectiveWh,
|
||||
effectiveLoc,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = (COALESCE(current_qty::numeric, 0) - $4::numeric)::text,
|
||||
updated_date = NOW(), writer = $5
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND warehouse_code = $3 AND location_code = $6`,
|
||||
[
|
||||
companyCode,
|
||||
childItemCode,
|
||||
effectiveWh,
|
||||
String(inputQty),
|
||||
userId,
|
||||
effectiveLoc,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// [WIP 적재 — 트리거 2] 실적 저장분(양품/불량 증분)을 wip_stock 에 반영
|
||||
// SAVEPOINT 로 감싼다: 공유 트랜잭션(client)에서 WIP 쿼리가 throw 하면
|
||||
// PG 가 트랜잭션 전체를 aborted 로 만들어 이후 COMMIT 이 ROLLBACK 처리된다.
|
||||
@@ -3939,3 +4040,289 @@ export const getProcessResult = async (
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
};
|
||||
export const autoCompleteProcess = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
) => {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { work_order_process_id } = req.body;
|
||||
|
||||
if (!work_order_process_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "work_order_process_id는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const wopr = await pool.query(
|
||||
`SELECT wr.id, wr.input_qty, wr.status, wr.result_status, wr.wop_id
|
||||
FROM work_order_process_result wr
|
||||
WHERE wr.id = $1 AND wr.company_code = $2`,
|
||||
[work_order_process_id, companyCode],
|
||||
);
|
||||
|
||||
if (wopr.rowCount === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "접수 카드를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const prev = wopr.rows[0];
|
||||
if (prev.result_status === "confirmed" || prev.status === "completed") {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ success: false, message: "이미 확정된 실적입니다." });
|
||||
}
|
||||
|
||||
const prCheck = await pool.query(
|
||||
`SELECT COUNT(*)::int AS cnt FROM process_work_result
|
||||
WHERE work_order_process_id = $1 AND company_code = $2
|
||||
AND detail_type = 'production_result'`,
|
||||
[work_order_process_id, companyCode],
|
||||
);
|
||||
if ((prCheck.rows[0]?.cnt ?? 0) > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "실적등록 항목이 존재하는 공정은 자동 완료할 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const acceptedQty = parseInt(prev.input_qty, 10) || 0;
|
||||
if (acceptedQty <= 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, message: "접수 수량이 없습니다." });
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const result = await client.query(
|
||||
`UPDATE work_order_process_result
|
||||
SET status = 'completed',
|
||||
result_status = 'confirmed',
|
||||
total_production_qty = $3,
|
||||
good_qty = $3,
|
||||
defect_qty = '0',
|
||||
concession_qty = '0',
|
||||
completed_at = NOW()::text,
|
||||
completed_by = $4,
|
||||
writer = $4,
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty, wop_id`,
|
||||
[work_order_process_id, companyCode, String(acceptedQty), userId],
|
||||
);
|
||||
|
||||
const wopLookup = await client.query(
|
||||
`SELECT wo_id FROM work_order_process WHERE id = $1 AND company_code = $2`,
|
||||
[result.rows[0].wop_id, companyCode],
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
if ((wopLookup.rowCount ?? 0) > 0) {
|
||||
await checkAndCompleteWorkInstruction(
|
||||
pool,
|
||||
wopLookup.rows[0].wo_id,
|
||||
companyCode,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (txErr) {
|
||||
await client.query("ROLLBACK");
|
||||
throw txErr;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("[pop/production] auto-complete 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "자동 완료 처리 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 실적 이력 조회 — work_order_process_result 기준 (wop_result.id)
|
||||
*/
|
||||
export const savePackaging = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
) => {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const {
|
||||
work_order_process_result_id,
|
||||
packages,
|
||||
loading_code,
|
||||
loading_name,
|
||||
} = req.body;
|
||||
|
||||
if (!work_order_process_result_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "work_order_process_result_id는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const procInfo = await pool.query(
|
||||
`SELECT wr.id, wr.wop_id, wop.wo_id,
|
||||
wi.work_instruction_no
|
||||
FROM work_order_process_result wr
|
||||
JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code
|
||||
JOIN work_instruction wi ON wi.id = wop.wo_id AND wi.company_code = wop.company_code
|
||||
WHERE wr.id = $1 AND wr.company_code = $2`,
|
||||
[work_order_process_result_id, companyCode],
|
||||
);
|
||||
|
||||
if (procInfo.rowCount === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "실적을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const proc = procInfo.rows[0];
|
||||
const labelPrefix: string =
|
||||
proc.work_instruction_no || work_order_process_result_id;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
await client.query(
|
||||
`DELETE FROM transaction_packaging
|
||||
WHERE company_code = $1
|
||||
AND source_type = 'work_order_process_result'
|
||||
AND source_id = $2`,
|
||||
[companyCode, work_order_process_result_id],
|
||||
);
|
||||
|
||||
const loadingId = await ensureLoadingInstance(client, {
|
||||
companyCode,
|
||||
sourceType: "work_order_process_result",
|
||||
sourceDocId: work_order_process_result_id,
|
||||
loadingCode: loading_code || null,
|
||||
loadingName: loading_name || null,
|
||||
loadingSeq: 1,
|
||||
writer: userId,
|
||||
});
|
||||
|
||||
const rawPkgs: any[] = Array.isArray(packages) ? packages : [];
|
||||
const packagesInput = rawPkgs
|
||||
.map((p) => ({
|
||||
pkg_code: String(p?.pkg_code ?? p?.unit?.value ?? ""),
|
||||
count: Number(p?.count ?? 0),
|
||||
qty_per_unit: Number(p?.qty_per_unit ?? p?.qtyPerUnit ?? 0),
|
||||
}))
|
||||
.filter((p) => p.pkg_code && p.count > 0 && p.qty_per_unit > 0);
|
||||
|
||||
let labels: string[] = [];
|
||||
if (packagesInput.length > 0) {
|
||||
labels = await insertPackagingRows(client, {
|
||||
companyCode,
|
||||
sourceType: "work_order_process_result",
|
||||
sourceId: work_order_process_result_id,
|
||||
inboundNumber: labelPrefix,
|
||||
packages: packagesInput,
|
||||
loadingId,
|
||||
warehouseCode: null,
|
||||
locationCode: null,
|
||||
writer: userId,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "포장/적재함이 저장되었습니다.",
|
||||
data: {
|
||||
loading_id: loadingId,
|
||||
loading_code: loading_code || null,
|
||||
loading_name: loading_name || null,
|
||||
packages: packagesInput,
|
||||
labels,
|
||||
},
|
||||
});
|
||||
} catch (txErr: any) {
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
throw txErr;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("[pop/production] save-packaging 오류:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, message: error.message || "포장/적재함 저장 오류" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 공정 실적에 저장된 포장단위/적재함 조회
|
||||
*/
|
||||
export const getProcessPackaging = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
) => {
|
||||
const pool = getPool();
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { wopResultId } = req.params;
|
||||
|
||||
if (!wopResultId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "wopResultId는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const pkgResult = await pool.query(
|
||||
`SELECT id, package_label, pkg_code, quantity, loading_id, seq_no, status
|
||||
FROM transaction_packaging
|
||||
WHERE company_code = $1
|
||||
AND source_type = 'work_order_process_result'
|
||||
AND source_id = $2
|
||||
ORDER BY seq_no ASC`,
|
||||
[companyCode, wopResultId],
|
||||
);
|
||||
|
||||
const loadingResult = await pool.query(
|
||||
`SELECT tl.id, tl.loading_code, tl.loading_name, tl.loading_seq,
|
||||
lu.loading_type, lu.max_load_kg, lu.max_stack
|
||||
FROM transaction_loading tl
|
||||
LEFT JOIN loading_unit lu ON lu.loading_code = tl.loading_code AND lu.company_code = tl.company_code
|
||||
WHERE tl.company_code = $1
|
||||
AND tl.source_type = 'work_order_process_result'
|
||||
AND tl.source_doc_id = $2
|
||||
ORDER BY tl.loading_seq ASC
|
||||
LIMIT 1`,
|
||||
[companyCode, wopResultId],
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
loading: loadingResult.rows[0] || null,
|
||||
packages: pkgResult.rows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("[pop/production] get-packaging 오류:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, message: error.message || "포장 조회 오류" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1503,3 +1503,56 @@ export async function getMaterialOverrides(req: AuthenticatedRequest, res: Respo
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 작업지시 공정별 실적 (생산실적 우측 패널용)
|
||||
// work_order_process LEFT JOIN work_order_process_result, 카드(result) 단위로 flat 반환.
|
||||
// 공정에 카드가 없으면 wr.* 컬럼들이 모두 NULL 인 한 행을 반환.
|
||||
export async function getProcessResults(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { wiId } = req.params;
|
||||
if (!wiId) return res.status(400).json({ success: false, message: "wiId는 필수입니다." });
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
wop.id AS wop_id,
|
||||
wop.wo_id,
|
||||
wop.seq_no AS process_seq_no,
|
||||
wop.process_code,
|
||||
wop.process_name,
|
||||
wr.id AS result_id,
|
||||
wr.seq AS result_seq,
|
||||
wr.equipment_code,
|
||||
wr.input_qty,
|
||||
wr.good_qty,
|
||||
wr.defect_qty,
|
||||
wr.concession_qty,
|
||||
wr.total_production_qty,
|
||||
wr.started_at,
|
||||
wr.completed_at,
|
||||
wr.status,
|
||||
wr.result_status,
|
||||
wr.result_note,
|
||||
wr.defect_detail,
|
||||
wr.is_rework,
|
||||
wr.rework_source_id
|
||||
FROM work_order_process wop
|
||||
LEFT JOIN work_order_process_result wr
|
||||
ON wr.wop_id = wop.id
|
||||
AND wr.company_code = wop.company_code
|
||||
WHERE wop.wo_id = $1 AND wop.company_code = $2
|
||||
ORDER BY
|
||||
CASE WHEN wop.seq_no::text ~ '^[0-9]+$' THEN wop.seq_no::text::int ELSE NULL END NULLS LAST,
|
||||
wop.seq_no::text,
|
||||
CASE WHEN wr.seq::text ~ '^[0-9]+$' THEN wr.seq::text::int ELSE NULL END NULLS LAST,
|
||||
wr.seq::text`,
|
||||
[wiId, companyCode]
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ router.post("/bom-base-qty", ctrl.getBomBaseQtyMap);
|
||||
// BOM 트리 조회 (작업지시 등록/수정 모달의 자재 트리 섹션용 — TASK:ERP-node-090 트리화)
|
||||
router.get("/bom-tree/:itemCode", ctrl.getBomTree);
|
||||
|
||||
// 작업지시별 공정 실적 집계 (생산실적 화면 우측 패널)
|
||||
router.get("/:wiId/process-results", ctrl.getProcessResults);
|
||||
|
||||
// 라우팅 & 공정작업기준
|
||||
router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions);
|
||||
router.put("/:wiNo/routing", ctrl.updateRouting);
|
||||
|
||||
@@ -66,6 +66,31 @@ interface SelectedItem {
|
||||
equipmentIds?: string[];
|
||||
workTeams?: string[];
|
||||
workers?: string[];
|
||||
// 기준수(BOM 0레벨 base_qty) / 배치수(자동) / 배분(균등|순차)
|
||||
baseQty?: number | null;
|
||||
splitMode?: "even" | "sequential";
|
||||
}
|
||||
|
||||
// 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1
|
||||
function calcBatchCount(qty: number, baseQty: number | null | undefined): number {
|
||||
const b = Number(baseQty || 0);
|
||||
if (!Number.isFinite(b) || b <= 0) return 1;
|
||||
if (!Number.isFinite(qty) || qty <= 0) return 1;
|
||||
return qty > b ? Math.ceil(qty / b) : 1;
|
||||
}
|
||||
|
||||
// 분할 산출: batchCount<=1이면 [qty], 아니면 mode 따라 분할
|
||||
function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" | "sequential"): number[] {
|
||||
if (batchCount <= 1) return [qty];
|
||||
if (mode === "sequential") {
|
||||
const head = Array(batchCount - 1).fill(baseQty);
|
||||
const tail = qty - baseQty * (batchCount - 1);
|
||||
return [...head, tail];
|
||||
}
|
||||
// even: 앞은 floor, 마지막이 잔여 흡수
|
||||
const base = Math.floor(qty / batchCount);
|
||||
const remainder = qty - base * (batchCount - 1);
|
||||
return [...Array(batchCount - 1).fill(base), remainder];
|
||||
}
|
||||
|
||||
// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용)
|
||||
@@ -368,14 +393,27 @@ export default function WorkInstructionPage() {
|
||||
const headerEquipment = first?.equipmentIds?.[0] || "";
|
||||
const headerWorkTeam = first?.workTeams?.[0] || "";
|
||||
const headerWorker = first?.workers?.[0] || "";
|
||||
// 배치수≥2인 품목은 splitMode에 따라 N건으로 펼친다.
|
||||
const expandedItems: Array<typeof confirmItems[number] & { _qty: number }> = [];
|
||||
for (const i of confirmItems) {
|
||||
const qty = Number(i.qty || 0);
|
||||
const baseQty = Number(i.baseQty || 0);
|
||||
const batchCount = calcBatchCount(qty, i.baseQty);
|
||||
if (batchCount > 1 && baseQty > 0) {
|
||||
const parts = splitQty(qty, baseQty, batchCount, i.splitMode || "even");
|
||||
for (const p of parts) expandedItems.push({ ...i, _qty: p });
|
||||
} else {
|
||||
expandedItems.push({ ...i, _qty: qty });
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
status: confirmStatus,
|
||||
startDate: headerStart, endDate: headerEnd,
|
||||
equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker,
|
||||
routing: confirmRouting || null,
|
||||
infos: confirmInfos.map((s) => s.trim()).filter(Boolean),
|
||||
items: confirmItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark,
|
||||
items: expandedItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i._qty), remark: i.remark,
|
||||
sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode,
|
||||
routing: i.routing || null,
|
||||
// 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분)
|
||||
|
||||
@@ -66,6 +66,31 @@ interface SelectedItem {
|
||||
equipmentIds?: string[];
|
||||
workTeams?: string[];
|
||||
workers?: string[];
|
||||
// 기준수(BOM 0레벨 base_qty) / 배치수(자동) / 배분(균등|순차)
|
||||
baseQty?: number | null;
|
||||
splitMode?: "even" | "sequential";
|
||||
}
|
||||
|
||||
// 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1
|
||||
function calcBatchCount(qty: number, baseQty: number | null | undefined): number {
|
||||
const b = Number(baseQty || 0);
|
||||
if (!Number.isFinite(b) || b <= 0) return 1;
|
||||
if (!Number.isFinite(qty) || qty <= 0) return 1;
|
||||
return qty > b ? Math.ceil(qty / b) : 1;
|
||||
}
|
||||
|
||||
// 분할 산출: batchCount<=1이면 [qty], 아니면 mode 따라 분할
|
||||
function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" | "sequential"): number[] {
|
||||
if (batchCount <= 1) return [qty];
|
||||
if (mode === "sequential") {
|
||||
const head = Array(batchCount - 1).fill(baseQty);
|
||||
const tail = qty - baseQty * (batchCount - 1);
|
||||
return [...head, tail];
|
||||
}
|
||||
// even: 앞은 floor, 마지막이 잔여 흡수
|
||||
const base = Math.floor(qty / batchCount);
|
||||
const remainder = qty - base * (batchCount - 1);
|
||||
return [...Array(batchCount - 1).fill(base), remainder];
|
||||
}
|
||||
|
||||
// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용)
|
||||
@@ -372,14 +397,27 @@ export default function WorkInstructionPage() {
|
||||
const headerEquipment = first?.equipmentIds?.[0] || "";
|
||||
const headerWorkTeam = first?.workTeams?.[0] || "";
|
||||
const headerWorker = first?.workers?.[0] || "";
|
||||
// 배치수≥2인 품목은 splitMode에 따라 N건으로 펼친다.
|
||||
const expandedItems: Array<typeof confirmItems[number] & { _qty: number }> = [];
|
||||
for (const i of confirmItems) {
|
||||
const qty = Number(i.qty || 0);
|
||||
const baseQty = Number(i.baseQty || 0);
|
||||
const batchCount = calcBatchCount(qty, i.baseQty);
|
||||
if (batchCount > 1 && baseQty > 0) {
|
||||
const parts = splitQty(qty, baseQty, batchCount, i.splitMode || "even");
|
||||
for (const p of parts) expandedItems.push({ ...i, _qty: p });
|
||||
} else {
|
||||
expandedItems.push({ ...i, _qty: qty });
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
status: confirmStatus,
|
||||
startDate: headerStart, endDate: headerEnd,
|
||||
equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker,
|
||||
routing: confirmRouting || null,
|
||||
infos: confirmInfos.map((s) => s.trim()).filter(Boolean),
|
||||
items: confirmItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark,
|
||||
items: expandedItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i._qty), remark: i.remark,
|
||||
sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode,
|
||||
routing: i.routing || null,
|
||||
// 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분)
|
||||
|
||||
@@ -66,6 +66,31 @@ interface SelectedItem {
|
||||
equipmentIds?: string[];
|
||||
workTeams?: string[];
|
||||
workers?: string[];
|
||||
// 기준수(BOM 0레벨 base_qty) / 배치수(자동) / 배분(균등|순차)
|
||||
baseQty?: number | null;
|
||||
splitMode?: "even" | "sequential";
|
||||
}
|
||||
|
||||
// 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1
|
||||
function calcBatchCount(qty: number, baseQty: number | null | undefined): number {
|
||||
const b = Number(baseQty || 0);
|
||||
if (!Number.isFinite(b) || b <= 0) return 1;
|
||||
if (!Number.isFinite(qty) || qty <= 0) return 1;
|
||||
return qty > b ? Math.ceil(qty / b) : 1;
|
||||
}
|
||||
|
||||
// 분할 산출: batchCount<=1이면 [qty], 아니면 mode 따라 분할
|
||||
function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" | "sequential"): number[] {
|
||||
if (batchCount <= 1) return [qty];
|
||||
if (mode === "sequential") {
|
||||
const head = Array(batchCount - 1).fill(baseQty);
|
||||
const tail = qty - baseQty * (batchCount - 1);
|
||||
return [...head, tail];
|
||||
}
|
||||
// even: 앞은 floor, 마지막이 잔여 흡수
|
||||
const base = Math.floor(qty / batchCount);
|
||||
const remainder = qty - base * (batchCount - 1);
|
||||
return [...Array(batchCount - 1).fill(base), remainder];
|
||||
}
|
||||
|
||||
// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용)
|
||||
@@ -368,14 +393,27 @@ export default function WorkInstructionPage() {
|
||||
const headerEquipment = first?.equipmentIds?.[0] || "";
|
||||
const headerWorkTeam = first?.workTeams?.[0] || "";
|
||||
const headerWorker = first?.workers?.[0] || "";
|
||||
// 배치수≥2인 품목은 splitMode에 따라 N건으로 펼친다.
|
||||
const expandedItems: Array<typeof confirmItems[number] & { _qty: number }> = [];
|
||||
for (const i of confirmItems) {
|
||||
const qty = Number(i.qty || 0);
|
||||
const baseQty = Number(i.baseQty || 0);
|
||||
const batchCount = calcBatchCount(qty, i.baseQty);
|
||||
if (batchCount > 1 && baseQty > 0) {
|
||||
const parts = splitQty(qty, baseQty, batchCount, i.splitMode || "even");
|
||||
for (const p of parts) expandedItems.push({ ...i, _qty: p });
|
||||
} else {
|
||||
expandedItems.push({ ...i, _qty: qty });
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
status: confirmStatus,
|
||||
startDate: headerStart, endDate: headerEnd,
|
||||
equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker,
|
||||
routing: confirmRouting || null,
|
||||
infos: confirmInfos.map((s) => s.trim()).filter(Boolean),
|
||||
items: confirmItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark,
|
||||
items: expandedItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i._qty), remark: i.remark,
|
||||
sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode,
|
||||
routing: i.routing || null,
|
||||
// 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분)
|
||||
|
||||
@@ -66,6 +66,31 @@ interface SelectedItem {
|
||||
equipmentIds?: string[];
|
||||
workTeams?: string[];
|
||||
workers?: string[];
|
||||
// 기준수(BOM 0레벨 base_qty) / 배치수(자동) / 배분(균등|순차)
|
||||
baseQty?: number | null;
|
||||
splitMode?: "even" | "sequential";
|
||||
}
|
||||
|
||||
// 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1
|
||||
function calcBatchCount(qty: number, baseQty: number | null | undefined): number {
|
||||
const b = Number(baseQty || 0);
|
||||
if (!Number.isFinite(b) || b <= 0) return 1;
|
||||
if (!Number.isFinite(qty) || qty <= 0) return 1;
|
||||
return qty > b ? Math.ceil(qty / b) : 1;
|
||||
}
|
||||
|
||||
// 분할 산출: batchCount<=1이면 [qty], 아니면 mode 따라 분할
|
||||
function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" | "sequential"): number[] {
|
||||
if (batchCount <= 1) return [qty];
|
||||
if (mode === "sequential") {
|
||||
const head = Array(batchCount - 1).fill(baseQty);
|
||||
const tail = qty - baseQty * (batchCount - 1);
|
||||
return [...head, tail];
|
||||
}
|
||||
// even: 앞은 floor, 마지막이 잔여 흡수
|
||||
const base = Math.floor(qty / batchCount);
|
||||
const remainder = qty - base * (batchCount - 1);
|
||||
return [...Array(batchCount - 1).fill(base), remainder];
|
||||
}
|
||||
|
||||
// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용)
|
||||
@@ -368,14 +393,27 @@ export default function WorkInstructionPage() {
|
||||
const headerEquipment = first?.equipmentIds?.[0] || "";
|
||||
const headerWorkTeam = first?.workTeams?.[0] || "";
|
||||
const headerWorker = first?.workers?.[0] || "";
|
||||
// 배치수≥2인 품목은 splitMode에 따라 N건으로 펼친다.
|
||||
const expandedItems: Array<typeof confirmItems[number] & { _qty: number }> = [];
|
||||
for (const i of confirmItems) {
|
||||
const qty = Number(i.qty || 0);
|
||||
const baseQty = Number(i.baseQty || 0);
|
||||
const batchCount = calcBatchCount(qty, i.baseQty);
|
||||
if (batchCount > 1 && baseQty > 0) {
|
||||
const parts = splitQty(qty, baseQty, batchCount, i.splitMode || "even");
|
||||
for (const p of parts) expandedItems.push({ ...i, _qty: p });
|
||||
} else {
|
||||
expandedItems.push({ ...i, _qty: qty });
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
status: confirmStatus,
|
||||
startDate: headerStart, endDate: headerEnd,
|
||||
equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker,
|
||||
routing: confirmRouting || null,
|
||||
infos: confirmInfos.map((s) => s.trim()).filter(Boolean),
|
||||
items: confirmItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark,
|
||||
items: expandedItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i._qty), remark: i.remark,
|
||||
sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode,
|
||||
routing: i.routing || null,
|
||||
// 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분)
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ClipboardCheck,
|
||||
Inbox,
|
||||
Settings2,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -131,6 +132,9 @@ interface SelectedItem {
|
||||
batchUse?: "Y" | "N";
|
||||
// 미사용 품목의 사용자 수동 배치수 (기본 1 = 분할 없음)
|
||||
manualBatch?: number;
|
||||
// 수정 모드: 기존 detail row 식별 + 잠금 상태
|
||||
detailId?: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
// ── BOM 자재 매핑 행 (TASK:ERP-node-090, 트리화) ──
|
||||
@@ -1291,6 +1295,8 @@ export default function WorkInstructionPage() {
|
||||
infos: editInfos.map((s) => s.trim()).filter(Boolean),
|
||||
routing: editRouting || null,
|
||||
items: editItems.map((i) => ({
|
||||
// detailId 있으면 백엔드가 UPDATE, 없으면 INSERT 분류
|
||||
detailId: i.detailId || undefined,
|
||||
itemNumber: i.itemCode,
|
||||
itemCode: i.itemCode,
|
||||
qty: String(i.qty),
|
||||
@@ -2689,23 +2695,27 @@ export default function WorkInstructionPage() {
|
||||
editItems.map((item, idx) => {
|
||||
const editItemKey = makeItemKey(item.itemCode, item.sourceTable, item.sourceId);
|
||||
const editMatExpanded = editExpandedItems.has(editItemKey);
|
||||
const rowBg = item.locked ? "bg-amber-50/60" : "bg-background";
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<TableRow className="bg-background">
|
||||
<TableCell className="bg-background sticky left-0 z-20 text-center text-[13px]">
|
||||
{idx + 1}
|
||||
<TableRow className={rowBg}>
|
||||
<TableCell className={`${rowBg} sticky left-0 z-20 text-center text-[13px]`}>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{idx + 1}
|
||||
{item.locked && <Lock className="w-3 h-3 text-amber-600" aria-label="생산접수됨" />}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="bg-background sticky left-[50px] z-20 text-[13px] font-medium">
|
||||
<TableCell className={`${rowBg} sticky left-[50px] z-20 text-[13px] font-medium`}>
|
||||
{item.itemCode}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="bg-background sticky left-[180px] z-20 max-w-[180px] truncate text-sm"
|
||||
className={`${rowBg} sticky left-[180px] z-20 max-w-[180px] truncate text-sm`}
|
||||
title={item.itemName}
|
||||
>
|
||||
{item.itemName || "-"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="bg-background sticky left-[360px] z-20 truncate border-r text-[13px] shadow-[1px_0_0_0_hsl(var(--border))]"
|
||||
className={`${rowBg} sticky left-[360px] z-20 truncate border-r text-[13px] shadow-[1px_0_0_0_hsl(var(--border))]`}
|
||||
title={item.spec}
|
||||
>
|
||||
{item.spec || "-"}
|
||||
@@ -2741,6 +2751,7 @@ export default function WorkInstructionPage() {
|
||||
type="number"
|
||||
className="ml-auto h-9 w-full min-w-[100px] text-sm"
|
||||
value={item.qty}
|
||||
disabled={item.locked}
|
||||
onChange={(e) =>
|
||||
setEditItems((prev) =>
|
||||
prev.map((it, i) => (i === idx ? { ...it, qty: Number(e.target.value) } : it)),
|
||||
@@ -2751,6 +2762,7 @@ export default function WorkInstructionPage() {
|
||||
<TableCell>
|
||||
<Select
|
||||
value={nv(item.routing || "")}
|
||||
disabled={item.locked}
|
||||
onValueChange={(v) => {
|
||||
const val = fromNv(v);
|
||||
setEditItems((prev) =>
|
||||
@@ -2758,7 +2770,7 @@ export default function WorkInstructionPage() {
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectTrigger className="h-9 text-sm" disabled={item.locked}>
|
||||
<SelectValue placeholder="라우팅" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2887,6 +2899,8 @@ export default function WorkInstructionPage() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
disabled={item.locked}
|
||||
title={item.locked ? "이미 생산접수된 row는 삭제할 수 없습니다" : "삭제"}
|
||||
onClick={() => setEditItems((prev) => prev.filter((_, i) => i !== idx))}
|
||||
>
|
||||
<X className="text-destructive h-3 w-3" />
|
||||
|
||||
@@ -216,12 +216,9 @@ export default function InboundOutboundPage() {
|
||||
try {
|
||||
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
autoFilter: { enabled: false }, // 회사 스코프 해제 (슈퍼관리자 포함)
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "user_id", operator: "in", value: writerIds }],
|
||||
},
|
||||
size: writerIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "user_id", operator: "in", value: writerIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
const uMap: Record<string, string> = {};
|
||||
|
||||
@@ -66,6 +66,31 @@ interface SelectedItem {
|
||||
equipmentIds?: string[];
|
||||
workTeams?: string[];
|
||||
workers?: string[];
|
||||
// 기준수(BOM 0레벨 base_qty) / 배치수(자동) / 배분(균등|순차)
|
||||
baseQty?: number | null;
|
||||
splitMode?: "even" | "sequential";
|
||||
}
|
||||
|
||||
// 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1
|
||||
function calcBatchCount(qty: number, baseQty: number | null | undefined): number {
|
||||
const b = Number(baseQty || 0);
|
||||
if (!Number.isFinite(b) || b <= 0) return 1;
|
||||
if (!Number.isFinite(qty) || qty <= 0) return 1;
|
||||
return qty > b ? Math.ceil(qty / b) : 1;
|
||||
}
|
||||
|
||||
// 분할 산출: batchCount<=1이면 [qty], 아니면 mode 따라 분할
|
||||
function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" | "sequential"): number[] {
|
||||
if (batchCount <= 1) return [qty];
|
||||
if (mode === "sequential") {
|
||||
const head = Array(batchCount - 1).fill(baseQty);
|
||||
const tail = qty - baseQty * (batchCount - 1);
|
||||
return [...head, tail];
|
||||
}
|
||||
// even: 앞은 floor, 마지막이 잔여 흡수
|
||||
const base = Math.floor(qty / batchCount);
|
||||
const remainder = qty - base * (batchCount - 1);
|
||||
return [...Array(batchCount - 1).fill(base), remainder];
|
||||
}
|
||||
|
||||
// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용)
|
||||
@@ -368,14 +393,27 @@ export default function WorkInstructionPage() {
|
||||
const headerEquipment = first?.equipmentIds?.[0] || "";
|
||||
const headerWorkTeam = first?.workTeams?.[0] || "";
|
||||
const headerWorker = first?.workers?.[0] || "";
|
||||
// 배치수≥2인 품목은 splitMode에 따라 N건으로 펼친다.
|
||||
const expandedItems: Array<typeof confirmItems[number] & { _qty: number }> = [];
|
||||
for (const i of confirmItems) {
|
||||
const qty = Number(i.qty || 0);
|
||||
const baseQty = Number(i.baseQty || 0);
|
||||
const batchCount = calcBatchCount(qty, i.baseQty);
|
||||
if (batchCount > 1 && baseQty > 0) {
|
||||
const parts = splitQty(qty, baseQty, batchCount, i.splitMode || "even");
|
||||
for (const p of parts) expandedItems.push({ ...i, _qty: p });
|
||||
} else {
|
||||
expandedItems.push({ ...i, _qty: qty });
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
status: confirmStatus,
|
||||
startDate: headerStart, endDate: headerEnd,
|
||||
equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker,
|
||||
routing: confirmRouting || null,
|
||||
infos: confirmInfos.map((s) => s.trim()).filter(Boolean),
|
||||
items: confirmItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark,
|
||||
items: expandedItems.map(i => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i._qty), remark: i.remark,
|
||||
sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode,
|
||||
routing: i.routing || null,
|
||||
// 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분)
|
||||
|
||||
Reference in New Issue
Block a user