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:
kmh
2026-05-22 09:39:54 +09:00
parent 9da6b22a18
commit 15fa3e37f9
11 changed files with 821 additions and 28 deletions

View File

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

View File

@@ -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 || "포장 조회 오류" });
}
};

View File

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

View File

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

View File

@@ -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 — 다중값 쉼표 구분)

View File

@@ -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 — 다중값 쉼표 구분)

View File

@@ -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 — 다중값 쉼표 구분)

View File

@@ -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 — 다중값 쉼표 구분)

View File

@@ -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" />

View File

@@ -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> = {};

View File

@@ -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 — 다중값 쉼표 구분)