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

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