| @@ -9,12 +9,14 @@ import org.springframework.beans.factory.annotation.Autowired | |||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | import com.ffii.fpsms.modules.master.entity.ItemsRepository | ||||
| import com.ffii.fpsms.modules.master.entity.WarehouseRepository | import com.ffii.fpsms.modules.master.entity.WarehouseRepository | ||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||||
| import com.ffii.fpsms.modules.stock.service.StockInLineService | import com.ffii.fpsms.modules.stock.service.StockInLineService | ||||
| import com.ffii.fpsms.modules.stock.web.model.StockInRequest | import com.ffii.fpsms.modules.stock.web.model.StockInRequest | ||||
| import java.io.File | import java.io.File | ||||
| import java.io.FileInputStream | import java.io.FileInputStream | ||||
| import java.io.IOException | import java.io.IOException | ||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import java.math.RoundingMode | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | ||||
| @@ -32,7 +34,9 @@ open class InventorySetup { | |||||
| @Autowired | @Autowired | ||||
| private lateinit var stockInLineService: StockInLineService | private lateinit var stockInLineService: StockInLineService | ||||
| private val DEFAULT_WAREHOUSE_CODE = "2F-W201-#A-01" | |||||
| @Autowired | |||||
| private lateinit var itemUomService: ItemUomService | |||||
| private val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd") | private val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy/MM/dd") | ||||
| @Transactional(rollbackFor = [Exception::class]) | @Transactional(rollbackFor = [Exception::class]) | ||||
| @@ -46,101 +50,98 @@ open class InventorySetup { | |||||
| val workbook: Workbook = XSSFWorkbook(inputStream) | val workbook: Workbook = XSSFWorkbook(inputStream) | ||||
| try { | try { | ||||
| val sheet: Sheet = workbook.getSheetAt(0) | |||||
| val inventoryDataList = mutableListOf<InventoryData>() | val inventoryDataList = mutableListOf<InventoryData>() | ||||
| // Column indices | |||||
| val COLUMN_ITEM_NO_INDEX = 6 // Column G | |||||
| val COLUMN_STORE_ID_INDEX = 14 // Column O | |||||
| val COLUMN_WAREHOUSE_INDEX = 16 // Column Q | |||||
| val COLUMN_AREA_INDEX = 17 // Column R | |||||
| val COLUMN_SLOT_INDEX = 18 // Column S | |||||
| val COLUMN_PRODUCT_LOT_NO_INDEX = 21 // Column V | |||||
| val COLUMN_QUANTITY_INDEX = 22 // Column W | |||||
| val COLUMN_EXPIRY_DATE_INDEX = 26 // Column AA | |||||
| // New template: B=store_id, C=warehouse, D=area, E=slot, F=item code, J=productionLotNo, K=quantity (sales unit), O=expiry date | |||||
| val COLUMN_STORE_ID_INDEX = 1 // B | |||||
| val COLUMN_WAREHOUSE_INDEX = 2 // C | |||||
| val COLUMN_AREA_INDEX = 3 // D | |||||
| val COLUMN_SLOT_INDEX = 4 // E | |||||
| val COLUMN_ITEM_CODE_INDEX = 5 // F | |||||
| val COLUMN_PRODUCT_LOT_NO_INDEX = 9 // J | |||||
| val COLUMN_QUANTITY_INDEX = 10 // K (sales unit) | |||||
| val COLUMN_EXPIRY_DATE_INDEX = 14 // O | |||||
| val START_ROW_INDEX = 2 // Start from Excel row 3 (0-based: index 2) | |||||
| val START_ROW_INDEX = 6 // Start from Excel row 7 (0-based: index 6) | |||||
| // ──────────────────────────────────────────────── | // ──────────────────────────────────────────────── | ||||
| // Process data rows (starting from Excel row 3, index 2) | |||||
| // Process all sheets, data rows from row 7 | |||||
| // ──────────────────────────────────────────────── | // ──────────────────────────────────────────────── | ||||
| for (rowIndex in START_ROW_INDEX..sheet.lastRowNum) { | |||||
| val row = sheet.getRow(rowIndex) ?: continue | |||||
| for (sheetIndex in 0 until workbook.numberOfSheets) { | |||||
| val sheet: Sheet = workbook.getSheetAt(sheetIndex) | |||||
| val sheetName = sheet.sheetName | |||||
| for (rowIndex in START_ROW_INDEX..sheet.lastRowNum) { | |||||
| val row = sheet.getRow(rowIndex) ?: continue | |||||
| val itemNo = ExcelUtils.getStringValue(row.getCell(COLUMN_ITEM_NO_INDEX))?.trim() ?: "" | |||||
| val itemNo = ExcelUtils.getStringValue(row.getCell(COLUMN_ITEM_CODE_INDEX))?.trim() ?: "" | |||||
| // Skip if itemNo is empty (required field) | |||||
| if (itemNo.isEmpty()) { | |||||
| println("SKIP row ${rowIndex + 1} - Missing itemNo") | |||||
| continue | |||||
| } | |||||
| if (itemNo.isEmpty()) { | |||||
| println("SKIP sheet '$sheetName' row ${rowIndex + 1} - Missing item code") | |||||
| continue | |||||
| } | |||||
| // Read warehouse location fields | |||||
| val store_id = ExcelUtils.getStringValue(row.getCell(COLUMN_STORE_ID_INDEX))?.trim() ?: "" | |||||
| val warehouse = ExcelUtils.getStringValue(row.getCell(COLUMN_WAREHOUSE_INDEX))?.trim() ?: "" | |||||
| val area = ExcelUtils.getStringValue(row.getCell(COLUMN_AREA_INDEX))?.trim() ?: "" | |||||
| val slot = ExcelUtils.getStringValue(row.getCell(COLUMN_SLOT_INDEX))?.trim() ?: "" | |||||
| // Generate LocationCode: store_id-warehouse-area-slot | |||||
| val locationCode = if (store_id.isNotEmpty() && warehouse.isNotEmpty() && | |||||
| area.isNotEmpty() && slot.isNotEmpty()) { | |||||
| "$store_id-$warehouse-$area-$slot" | |||||
| } else { | |||||
| "" | |||||
| } | |||||
| val store_id = ExcelUtils.getStringValue(row.getCell(COLUMN_STORE_ID_INDEX))?.trim() ?: "" | |||||
| val warehouse = ExcelUtils.getStringValue(row.getCell(COLUMN_WAREHOUSE_INDEX))?.trim() ?: "" | |||||
| val area = ExcelUtils.getStringValue(row.getCell(COLUMN_AREA_INDEX))?.trim() ?: "" | |||||
| val slot = ExcelUtils.getStringValue(row.getCell(COLUMN_SLOT_INDEX))?.trim() ?: "" | |||||
| val quantityStr = ExcelUtils.getStringValue(row.getCell(COLUMN_QUANTITY_INDEX))?.trim() | |||||
| val quantity = quantityStr?.toBigDecimalOrNull() | |||||
| val locationCode = if (store_id.isNotEmpty() && warehouse.isNotEmpty() && | |||||
| area.isNotEmpty() && slot.isNotEmpty()) { | |||||
| "$store_id-$warehouse-$area-$slot" | |||||
| } else { | |||||
| "" | |||||
| } | |||||
| if (quantity == null || quantity <= BigDecimal.ZERO) { | |||||
| println("SKIP row ${rowIndex + 1} - Invalid quantity: $quantityStr") | |||||
| continue | |||||
| } | |||||
| if (locationCode.isEmpty()) { | |||||
| println("SKIP sheet '$sheetName' row ${rowIndex + 1} - No location (store_id/warehouse/area/slot required)") | |||||
| continue | |||||
| } | |||||
| val productLotNo = ExcelUtils.getStringValue(row.getCell(COLUMN_PRODUCT_LOT_NO_INDEX))?.trim() | |||||
| val quantityStr = ExcelUtils.getStringValue(row.getCell(COLUMN_QUANTITY_INDEX))?.trim() | |||||
| val salesQuantity = quantityStr?.toBigDecimalOrNull() | |||||
| // Parse expiry date with fallback to default (1 month later) | |||||
| val expiryDateStr = ExcelUtils.getStringValue(row.getCell(COLUMN_EXPIRY_DATE_INDEX))?.trim() | |||||
| val expiryDate = expiryDateStr?.let { | |||||
| try { | |||||
| LocalDate.parse(it, DATE_FORMAT) | |||||
| } catch (e: Exception) { | |||||
| println("WARNING row ${rowIndex + 1} - Invalid expiry date format: $it (expected YYYY/MM/DD), using default: 1 month later") | |||||
| null | |||||
| if (salesQuantity == null || salesQuantity <= BigDecimal.ZERO) { | |||||
| println("SKIP sheet '$sheetName' row ${rowIndex + 1} - Invalid or zero quantity: $quantityStr") | |||||
| continue | |||||
| } | } | ||||
| } ?: run { | |||||
| // If expiryDateStr is null or empty, or parsing failed, use default | |||||
| LocalDate.now().plusMonths(1) | |||||
| } | |||||
| // If parsing failed, use default | |||||
| val finalExpiryDate = expiryDate ?: LocalDate.now().plusMonths(1) | |||||
| println("=== Processing row ${rowIndex + 1} (itemNo: $itemNo, qty: $quantity, expiryDate: $finalExpiryDate, locationCode: ${locationCode.ifEmpty { "DEFAULT" }}) ===") | |||||
| val productLotNo = ExcelUtils.getStringValue(row.getCell(COLUMN_PRODUCT_LOT_NO_INDEX))?.trim() | |||||
| inventoryDataList.add( | |||||
| InventoryData( | |||||
| itemNo = itemNo, | |||||
| locationCode = locationCode, | |||||
| quantity = quantity, | |||||
| productLotNo = productLotNo, | |||||
| expiryDate = finalExpiryDate | |||||
| val expiryDateStr = ExcelUtils.getStringValue(row.getCell(COLUMN_EXPIRY_DATE_INDEX))?.trim() | |||||
| val expiryDate = expiryDateStr?.let { | |||||
| try { | |||||
| LocalDate.parse(it, DATE_FORMAT) | |||||
| } catch (e: Exception) { | |||||
| println("WARNING sheet '$sheetName' row ${rowIndex + 1} - Invalid expiry date format: $it (expected yyyy/MM/dd), using default: 1 month later") | |||||
| null | |||||
| } | |||||
| } ?: LocalDate.now().plusMonths(1) | |||||
| val finalExpiryDate = expiryDate ?: LocalDate.now().plusMonths(1) | |||||
| inventoryDataList.add( | |||||
| InventoryData( | |||||
| itemNo = itemNo, | |||||
| locationCode = locationCode, | |||||
| quantitySalesUnit = salesQuantity, | |||||
| productLotNo = productLotNo, | |||||
| expiryDate = finalExpiryDate | |||||
| ) | |||||
| ) | ) | ||||
| ) | |||||
| } | |||||
| } | } | ||||
| println("Total valid rows collected: ${inventoryDataList.size}") | println("Total valid rows collected: ${inventoryDataList.size}") | ||||
| // ──────────────────────────────────────────────── | // ──────────────────────────────────────────────── | ||||
| // Create stock in for each item | |||||
| // Create stock in (convert sales qty -> stock qty per item) | |||||
| // ──────────────────────────────────────────────── | // ──────────────────────────────────────────────── | ||||
| var createdCount = 0 | var createdCount = 0 | ||||
| var notFoundCount = 0 | var notFoundCount = 0 | ||||
| var warehouseNotFoundCount = 0 | |||||
| var warehouseSkippedCount = 0 | |||||
| for (data in inventoryDataList) { | for (data in inventoryDataList) { | ||||
| try { | try { | ||||
| // Find item by code (itemNo) | |||||
| val item = itemsRepository.findFirstByCodeAndDeletedFalse(data.itemNo) | val item = itemsRepository.findFirstByCodeAndDeletedFalse(data.itemNo) | ||||
| if (item == null) { | if (item == null) { | ||||
| @@ -154,23 +155,11 @@ open class InventorySetup { | |||||
| continue | continue | ||||
| } | } | ||||
| // Find warehouse by LocationCode, fallback to default if not found | |||||
| var warehouse = if (data.locationCode.isNotEmpty()) { | |||||
| warehouseRepository.findByCodeAndDeletedIsFalse(data.locationCode) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| // If warehouse not found, use default warehouse | |||||
| val warehouse = warehouseRepository.findByCodeAndDeletedIsFalse(data.locationCode) | |||||
| if (warehouse == null) { | if (warehouse == null) { | ||||
| println("WAREHOUSE NOT FOUND - Warehouse with code '${data.locationCode}' does not exist, using default: $DEFAULT_WAREHOUSE_CODE") | |||||
| warehouse = warehouseRepository.findByCodeAndDeletedIsFalse(DEFAULT_WAREHOUSE_CODE) | |||||
| warehouseNotFoundCount++ | |||||
| if (warehouse == null) { | |||||
| println("ERROR - Default warehouse '$DEFAULT_WAREHOUSE_CODE' also not found in database") | |||||
| continue | |||||
| } | |||||
| println("SKIP - No warehouse for location '${data.locationCode}' (no default)") | |||||
| warehouseSkippedCount++ | |||||
| continue | |||||
| } | } | ||||
| if (warehouse.id == null) { | if (warehouse.id == null) { | ||||
| @@ -178,18 +167,34 @@ open class InventorySetup { | |||||
| continue | continue | ||||
| } | } | ||||
| // Generate dnNo: LT-YYYYMMDD-itemCode | |||||
| val stockQuantityRaw = itemUomService.findSalesUnitByItemId(item.id!!)?.uom?.id?.let { salesUomId -> | |||||
| itemUomService.convertQtyToStockQty(item.id!!, salesUomId, data.quantitySalesUnit) | |||||
| } ?: data.quantitySalesUnit | |||||
| val stockQuantity = stockQuantityRaw.setScale(0, RoundingMode.FLOOR) | |||||
| if (stockQuantity != stockQuantityRaw) { | |||||
| println("FLOOR applied for item ${data.itemNo}: $stockQuantityRaw -> $stockQuantity (sales qty: ${data.quantitySalesUnit})") | |||||
| } | |||||
| if (stockQuantity <= BigDecimal.ZERO) { | |||||
| if (stockQuantityRaw > BigDecimal.ZERO) { | |||||
| println("SKIP - Stock quantity became 0 after floor for item ${data.itemNo} (sales qty: ${data.quantitySalesUnit}, converted before floor: $stockQuantityRaw)") | |||||
| } else { | |||||
| println("SKIP - Stock quantity <= 0 for item ${data.itemNo} (sales qty: ${data.quantitySalesUnit})") | |||||
| } | |||||
| continue | |||||
| } | |||||
| val today = LocalDate.now() | val today = LocalDate.now() | ||||
| val dnNo = "LT-${today.format(DateTimeFormatter.ofPattern("yyyyMMdd"))}-${data.itemNo}" | val dnNo = "LT-${today.format(DateTimeFormatter.ofPattern("yyyyMMdd"))}-${data.itemNo}" | ||||
| // Use generic stock in | |||||
| val stockInRequest = StockInRequest( | val stockInRequest = StockInRequest( | ||||
| itemId = item.id!!, | itemId = item.id!!, | ||||
| itemNo = data.itemNo, | itemNo = data.itemNo, | ||||
| demandQty = data.quantity, | |||||
| acceptedQty = data.quantity, | |||||
| demandQty = stockQuantity, | |||||
| acceptedQty = stockQuantity, | |||||
| expiryDate = data.expiryDate, | expiryDate = data.expiryDate, | ||||
| lotNo = null, // System will auto-generate | |||||
| lotNo = null, | |||||
| productLotNo = data.productLotNo, | productLotNo = data.productLotNo, | ||||
| dnNo = dnNo, | dnNo = dnNo, | ||||
| type = "OPEN", | type = "OPEN", | ||||
| @@ -198,7 +203,7 @@ open class InventorySetup { | |||||
| stockInLineService.createStockIn(stockInRequest) | stockInLineService.createStockIn(stockInRequest) | ||||
| createdCount++ | createdCount++ | ||||
| println("CREATED stock in for item: ${data.itemNo} (dnNo: $dnNo, warehouse: ${warehouse.code})") | |||||
| println("CREATED stock in for item: ${data.itemNo} (sales qty: ${data.quantitySalesUnit} -> stock qty: $stockQuantity, warehouse: ${warehouse.code})") | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| println("Error processing inventory row (itemNo: ${data.itemNo}): ${e.message}") | println("Error processing inventory row (itemNo: ${data.itemNo}): ${e.message}") | ||||
| e.printStackTrace() | e.printStackTrace() | ||||
| @@ -206,7 +211,7 @@ open class InventorySetup { | |||||
| } | } | ||||
| } | } | ||||
| println("Import finished. Created $createdCount stock in records, Not found: $notFoundCount items, Warehouse not found (used default): $warehouseNotFoundCount.") | |||||
| println("Import finished. Created $createdCount stock in records, Not found: $notFoundCount items, Skipped (no location): $warehouseSkippedCount.") | |||||
| return createdCount | return createdCount | ||||
| } finally { | } finally { | ||||
| @@ -218,7 +223,7 @@ open class InventorySetup { | |||||
| data class InventoryData( | data class InventoryData( | ||||
| val itemNo: String, | val itemNo: String, | ||||
| val locationCode: String, | val locationCode: String, | ||||
| val quantity: BigDecimal, | |||||
| val quantitySalesUnit: BigDecimal, | |||||
| val productLotNo: String?, | val productLotNo: String?, | ||||
| val expiryDate: LocalDate | val expiryDate: LocalDate | ||||
| ) | ) | ||||