Enhance Outbound and Excel Service Functionality

- Added `inventory_unit` to the item selection query in the outbound controller to improve data retrieval.
- Updated the multi-table Excel service to exclude overlapping headers between parent and child levels, ensuring accurate data insertion.
- Introduced new category combobox components for better user interaction in the supplied item page.
- Enhanced the inbound-outbound page to correctly map user IDs, including super admin handling for user information retrieval.

(TASK: ERP-XXX)
This commit is contained in:
kjs
2026-05-20 15:24:19 +09:00
parent b2c96e616a
commit c5364e1d20
8 changed files with 2189 additions and 10 deletions

View File

@@ -740,7 +740,7 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const result = await pool.query(
`SELECT
id, item_number, item_name, size AS spec, material, unit,
id, item_number, item_name, size AS spec, material, unit, inventory_unit,
COALESCE(width::text, '') AS width,
COALESCE(height::text, '') AS height,
COALESCE(thickness::text, '') AS thickness,

View File

@@ -156,6 +156,17 @@ class MultiTableExcelService {
() => new Map()
);
// 자식 단계 빈 데이터 판정에 부모와 겹치는 헤더는 제외해야 한다.
// (예: customer_mng/customer_item_mapping 둘 다 '상태','비고' 헤더를 가지면
// 부모용으로 입력한 값이 자식에도 잘못 들어가 자식 INSERT가 시도된다.)
const ownHeadersPerLevel: Set<string>[] = activeLevels.map((lv, i) => {
const headers = new Set(lv.columns.map((c) => c.excelHeader));
for (let j = 0; j < i; j++) {
for (const c of activeLevels[j].columns) headers.delete(c.excelHeader);
}
return headers;
});
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
const row = rows[rowIdx];
@@ -178,8 +189,19 @@ class MultiTableExcelService {
}
}
const hasAnyData = Object.keys(levelData).length > 0;
if (!hasAnyData && lvlIdx > 0) {
// 자식 레벨은 '부모와 겹치지 않는 고유 헤더'에 값이 있을 때만 INSERT 진행
const ownHeaders = ownHeadersPerLevel[lvlIdx];
const hasOwnData =
lvlIdx === 0
? Object.keys(levelData).length > 0
: level.columns.some(
(c) =>
ownHeaders.has(c.excelHeader) &&
levelData[c.dbColumn] !== undefined &&
levelData[c.dbColumn] !== ""
);
if (!hasOwnData && lvlIdx > 0) {
break;
}

File diff suppressed because it is too large Load Diff

View File

@@ -203,12 +203,25 @@ export default function InboundOutboundPage() {
setLocationMap(locMap);
} catch { /* skip */ }
// 사용자 정보 조회 (writer → user_name 변환)
const writerIds = [...new Set(rows.map((r: any) => r.writer).filter(Boolean))];
// 사용자 정보 조회 (writer/manager_id → user_name 변환)
// 회사 스코프 autoFilter를 해제하고 명시 ID 필터로 조회 → 슈퍼관리자(company_code='*', 예: wace) 매핑 누락 방지
const writerIds = [
...new Set(
rows
.flatMap((r: any) => [r.writer, r.manager_id])
.filter((v: any) => typeof v === "string" && v.length > 0)
),
];
if (writerIds.length > 0) {
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 0, autoFilter: true,
page: 1,
size: 0,
autoFilter: { enabled: false }, // 회사 스코프 해제 (슈퍼관리자 포함)
dataFilter: {
enabled: true,
filters: [{ columnName: "user_id", operator: "in", value: writerIds }],
},
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
const uMap: Record<string, string> = {};

View File

@@ -276,7 +276,8 @@ export default function OutboundPage() {
const [dnPreview, setDnPreview] = useState<DeliveryNoteWithDetails | null>(null); // PDF 미리보기 대상
const [dnPrinting, setDnPrinting] = useState(false); // PDF 생성 중
// 카테고리 코드→라벨 매핑 (재질, 단위)
// 카테고리 코드→라벨 매핑 (재질, 재고단위, 단위)
// 출고 picker 단위는 재고단위(inventory_unit) 기준이 정본. 빈 값은 unit fallback.
const [catMap, setCatMap] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const flatten = (arr: any[]): { code: string; label: string }[] => {
@@ -289,7 +290,7 @@ export default function OutboundPage() {
};
const map: Record<string, Record<string, string>> = {};
Promise.all([
...["material", "inventory_unit"].map(async (col) => {
...["material", "inventory_unit", "unit"].map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_8`);
const items = flatten(res.data?.data || []);
@@ -978,7 +979,8 @@ export default function OutboundPage() {
item_name: item.item_name,
spec: item.spec || "",
material: item.material || "",
unit: (item as any).inventory_unit || "EA",
// 재고단위(inventory_unit) 우선, 비었으면 기본단위(unit) fallback, 그래도 없으면 "EA"
unit: item.inventory_unit || item.unit || "EA",
outbound_qty: 0,
unit_price: item.standard_price,
total_amount: 0,
@@ -2256,7 +2258,16 @@ function SourceItemTable({
</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={item.spec || "-"}>{item.spec || "-"}</TableCell>
<TableCell className="max-w-[100px] truncate p-2" title={resolveCat("material", item.material || "") || "-"}>{resolveCat("material", item.material || "") || "-"}</TableCell>
<TableCell className="p-2">{resolveCat("inventory_unit", item.unit || "") || "-"}</TableCell>
<TableCell className="p-2">{
// 재고단위(inventory_unit) 우선, 비었으면 기본단위(unit) fallback.
// 매칭 실패 시 resolveCat 내부에서 코드 fallback (빈칸 금지).
(() => {
const invLabel = resolveCat("inventory_unit", item.inventory_unit || "");
if (invLabel) return invLabel;
const unitLabel = resolveCat("unit", item.unit || "");
return unitLabel || "-";
})()
}</TableCell>
<TableCell className="p-2 text-right">
{Number(item.standard_price).toLocaleString()}
</TableCell>

View File

@@ -897,6 +897,7 @@ export default function PurchaseOrderPage() {
<DialogContent
className="max-w-[95vw] w-[1200px]"
style={{ maxHeight: "90vh", display: "flex", flexDirection: "column" }}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{isEditMode ? (isReadOnly ? "발주 상세" : "발주 수정") : "발주 등록"}</DialogTitle>

View File

@@ -99,6 +99,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_7/sales/order": dynamic(() => import("@/app/(main)/COMPANY_7/sales/order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/customer": dynamic(() => import("@/app/(main)/COMPANY_7/sales/customer/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/sales-item": dynamic(() => import("@/app/(main)/COMPANY_7/sales/sales-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/supplied-item": dynamic(() => import("@/app/(main)/COMPANY_7/sales/supplied-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_7/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_7/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_7/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
@@ -588,6 +589,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
"/COMPANY_7/sales/order": () => import("@/app/(main)/COMPANY_7/sales/order/page"),
"/COMPANY_7/sales/customer": () => import("@/app/(main)/COMPANY_7/sales/customer/page"),
"/COMPANY_7/sales/sales-item": () => import("@/app/(main)/COMPANY_7/sales/sales-item/page"),
"/COMPANY_7/sales/supplied-item": () => import("@/app/(main)/COMPANY_7/sales/supplied-item/page"),
"/COMPANY_7/sales/shipping-order": () => import("@/app/(main)/COMPANY_7/sales/shipping-order/page"),
"/COMPANY_7/sales/shipping-plan": () => import("@/app/(main)/COMPANY_7/sales/shipping-plan/page"),
"/COMPANY_7/sales/claim": () => import("@/app/(main)/COMPANY_7/sales/claim/page"),

View File

@@ -80,6 +80,7 @@ export interface ItemSource {
spec: string | null;
material: string | null;
unit: string | null;
inventory_unit?: string | null;
standard_price: number;
}