| @@ -4,7 +4,7 @@ import com.ffii.fpsms.modules.settings.entity.SettingsRepository | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.util.Locale | import java.util.Locale | ||||
| /** 供 DO 搜尋/車線/報表 SQL 等共用的 2F/4F 供應商代碼(來自 `settings` CSV)。 */ | |||||
| /** 供 DO 搜索/車線/報表 SQL 等共用的 2F/4F 供應商代碼(來自 `settings` CSV)。 */ | |||||
| @Service | @Service | ||||
| open class DoFloorSupplierSettingsService( | open class DoFloorSupplierSettingsService( | ||||
| private val settingsRepository: SettingsRepository, | private val settingsRepository: SettingsRepository, | ||||
| @@ -2,6 +2,8 @@ package com.ffii.fpsms.modules.master.entity | |||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import com.ffii.fpsms.modules.master.entity.projections.WarehouseCombo | import com.ffii.fpsms.modules.master.entity.projections.WarehouseCombo | ||||
| import org.springframework.data.domain.Page | |||||
| import org.springframework.data.domain.Pageable | |||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.io.Serializable | import java.io.Serializable | ||||
| @@ -19,4 +21,22 @@ interface WarehouseRepository : AbstractRepository<Warehouse, Long> { | |||||
| fun findDistinctStockTakeSectionsByDeletedIsFalse(): List<String>; | fun findDistinctStockTakeSectionsByDeletedIsFalse(): List<String>; | ||||
| fun findAllByIdIn(ids: List<Long>): List<Warehouse>; | fun findAllByIdIn(ids: List<Long>): List<Warehouse>; | ||||
| fun findAllByCodeAndDeletedIsFalse(code: String): List<Warehouse> | fun findAllByCodeAndDeletedIsFalse(code: String): List<Warehouse> | ||||
| @Query( | |||||
| """ | |||||
| SELECT COUNT(w) FROM Warehouse w | |||||
| WHERE w.deleted = false | |||||
| AND (w.stockTakeSection IS NULL OR TRIM(w.stockTakeSection) = '') | |||||
| """ | |||||
| ) | |||||
| fun countMissingStockTakeSection(): Long | |||||
| @Query( | |||||
| """ | |||||
| SELECT w FROM Warehouse w | |||||
| WHERE w.deleted = false | |||||
| AND (w.stockTakeSection IS NULL OR TRIM(w.stockTakeSection) = '') | |||||
| """ | |||||
| ) | |||||
| fun findMissingStockTakeSection(pageable: Pageable): Page<Warehouse> | |||||
| } | } | ||||
| @@ -26,8 +26,12 @@ import java.math.BigDecimal | |||||
| import kotlin.jvm.optionals.getOrNull | import kotlin.jvm.optionals.getOrNull | ||||
| import com.ffii.fpsms.modules.master.web.models.ExcelWarehouseData | import com.ffii.fpsms.modules.master.web.models.ExcelWarehouseData | ||||
| import com.ffii.core.exception.BadRequestException | import com.ffii.core.exception.BadRequestException | ||||
| import com.ffii.fpsms.modules.master.web.models.MissingStockTakeSectionIssueItem | |||||
| import com.ffii.fpsms.modules.master.web.models.MissingStockTakeSectionIssuesResponse | |||||
| import com.ffii.fpsms.modules.master.web.models.StockTakeSectionInfo | import com.ffii.fpsms.modules.master.web.models.StockTakeSectionInfo | ||||
| import com.ffii.fpsms.modules.master.web.models.UpdateSectionDescriptionRequest | import com.ffii.fpsms.modules.master.web.models.UpdateSectionDescriptionRequest | ||||
| import org.springframework.data.domain.PageRequest | |||||
| import org.springframework.data.domain.Sort | |||||
| @Service | @Service | ||||
| open class WarehouseService( | open class WarehouseService( | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| @@ -406,13 +410,48 @@ open class WarehouseService( | |||||
| return floorOrder * 10000L + letterOrder * 100L + slot | return floorOrder * 10000L + letterOrder * 100L + slot | ||||
| } | } | ||||
| open fun getMissingStockTakeSectionIssues(limit: Int): MissingStockTakeSectionIssuesResponse { | |||||
| val capped = limit.coerceIn(1, 200) | |||||
| val count = warehouseRepository.countMissingStockTakeSection() | |||||
| val sort = Sort.by(Sort.Order.asc("store_id"), Sort.Order.asc("code")) | |||||
| val page = warehouseRepository.findMissingStockTakeSection(PageRequest.of(0, capped, sort)) | |||||
| val items = page.content.map { w -> | |||||
| MissingStockTakeSectionIssueItem( | |||||
| id = w.id!!, | |||||
| code = w.code, | |||||
| storeId = w.store_id, | |||||
| warehouse = w.warehouse, | |||||
| area = w.area, | |||||
| slot = w.slot, | |||||
| order = w.order, | |||||
| ) | |||||
| } | |||||
| return MissingStockTakeSectionIssuesResponse( | |||||
| count = count, | |||||
| limit = capped, | |||||
| items = items, | |||||
| ) | |||||
| } | |||||
| open fun getStockTakeSections(): List<StockTakeSectionInfo> { | open fun getStockTakeSections(): List<StockTakeSectionInfo> { | ||||
| val all = warehouseRepository.findAllByDeletedIsFalse() | val all = warehouseRepository.findAllByDeletedIsFalse() | ||||
| .filter { it.stockTakeSection != null && it.stockTakeSection!!.isNotBlank() } | .filter { it.stockTakeSection != null && it.stockTakeSection!!.isNotBlank() } | ||||
| val grouped = all.groupBy { it.stockTakeSection!! } | val grouped = all.groupBy { it.stockTakeSection!! } | ||||
| return grouped.map { (section, list) -> | return grouped.map { (section, list) -> | ||||
| val desc = list.mapNotNull { it.stockTakeSectionDescription }.firstOrNull()?.trim() | val desc = list.mapNotNull { it.stockTakeSectionDescription }.firstOrNull()?.trim() | ||||
| StockTakeSectionInfo(section, desc, list.size.toLong()) | |||||
| val storeId = list.asSequence() | |||||
| .mapNotNull { it.store_id?.trim() } | |||||
| .firstOrNull { it.isNotBlank() } | |||||
| val warehouseArea = list.asSequence() | |||||
| .mapNotNull { it.area?.trim() } | |||||
| .firstOrNull { it.isNotBlank() } | |||||
| StockTakeSectionInfo( | |||||
| stockTakeSection = section, | |||||
| stockTakeSectionDescription = desc, | |||||
| warehouseCount = list.size.toLong(), | |||||
| storeId = storeId, | |||||
| warehouseArea = warehouseArea, | |||||
| ) | |||||
| }.sortedBy { it.stockTakeSection } | }.sortedBy { it.stockTakeSection } | ||||
| } | } | ||||
| @@ -25,8 +25,10 @@ import org.springframework.web.multipart.MultipartHttpServletRequest | |||||
| import net.sf.jasperreports.engine.JasperExportManager | import net.sf.jasperreports.engine.JasperExportManager | ||||
| import net.sf.jasperreports.engine.JasperPrint | import net.sf.jasperreports.engine.JasperPrint | ||||
| import java.io.OutputStream | import java.io.OutputStream | ||||
| import com.ffii.fpsms.modules.master.web.models.MissingStockTakeSectionIssuesResponse | |||||
| import com.ffii.fpsms.modules.master.web.models.StockTakeSectionInfo | import com.ffii.fpsms.modules.master.web.models.StockTakeSectionInfo | ||||
| import com.ffii.fpsms.modules.master.web.models.UpdateSectionDescriptionRequest | import com.ffii.fpsms.modules.master.web.models.UpdateSectionDescriptionRequest | ||||
| import org.springframework.web.bind.annotation.RequestParam | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/warehouse") | @RequestMapping("/warehouse") | ||||
| class WarehouseController( | class WarehouseController( | ||||
| @@ -102,6 +104,13 @@ class WarehouseController( | |||||
| fun printQrCode(@Valid @RequestBody request: PrintWarehouseQrCodeRequest) { | fun printQrCode(@Valid @RequestBody request: PrintWarehouseQrCodeRequest) { | ||||
| warehouseQrCodeService.printWarehouseQrCode(request) | warehouseQrCodeService.printWarehouseQrCode(request) | ||||
| } | } | ||||
| @GetMapping("/missingStockTakeSectionIssues") | |||||
| fun getMissingStockTakeSectionIssues( | |||||
| @RequestParam(defaultValue = "50") limit: Int, | |||||
| ): MissingStockTakeSectionIssuesResponse { | |||||
| return warehouseService.getMissingStockTakeSectionIssues(limit) | |||||
| } | |||||
| @GetMapping("/stockTakeSections") | @GetMapping("/stockTakeSections") | ||||
| fun getStockTakeSections(): List<StockTakeSectionInfo> { | fun getStockTakeSections(): List<StockTakeSectionInfo> { | ||||
| return warehouseService.getStockTakeSections() | return warehouseService.getStockTakeSections() | ||||
| @@ -0,0 +1,17 @@ | |||||
| package com.ffii.fpsms.modules.master.web.models | |||||
| data class MissingStockTakeSectionIssueItem( | |||||
| val id: Long, | |||||
| val code: String?, | |||||
| val storeId: String?, | |||||
| val warehouse: String?, | |||||
| val area: String?, | |||||
| val slot: String?, | |||||
| val order: String?, | |||||
| ) | |||||
| data class MissingStockTakeSectionIssuesResponse( | |||||
| val count: Long, | |||||
| val limit: Int, | |||||
| val items: List<MissingStockTakeSectionIssueItem>, | |||||
| ) | |||||
| @@ -46,4 +46,7 @@ open class StockTake: BaseEntity<Long>() { | |||||
| /** 同一輪盤點(多 section 多筆 stock_take)共用此 id;由批次建立時依 MAX+1 遞增,不必等於任一筆主鍵 */ | /** 同一輪盤點(多 section 多筆 stock_take)共用此 id;由批次建立時依 MAX+1 遞增,不必等於任一筆主鍵 */ | ||||
| @Column(name = "stockTakeRoundId") | @Column(name = "stockTakeRoundId") | ||||
| open var stockTakeRoundId: Long? = null | open var stockTakeRoundId: Long? = null | ||||
| @Size(max = 255) | |||||
| @Column(name = "stockTakeRoundName", length = 255) | |||||
| open var stockTakeRoundName: String? = null | |||||
| } | } | ||||
| @@ -316,6 +316,72 @@ open class StockTakeRecordService( | |||||
| } | } | ||||
| } | } | ||||
| /** 與 Approver 列表前端一致:優先第二次盤點數,否則第一次 */ | |||||
| private fun resolvePickerQtyForVarianceFilter(response: InventoryLotDetailResponse): BigDecimal { | |||||
| val second = response.secondStockTakeQty | |||||
| val first = response.firstStockTakeQty | |||||
| return when { | |||||
| second != null && second >= BigDecimal.ZERO -> second | |||||
| first != null -> first | |||||
| else -> BigDecimal.ZERO | |||||
| } | |||||
| } | |||||
| private fun computeInventoryLotDetailVariancePercentage(response: InventoryLotDetailResponse): BigDecimal { | |||||
| val selectedQty = resolvePickerQtyForVarianceFilter(response) | |||||
| val bookQty = response.bookQty ?: response.availableQty ?: BigDecimal.ZERO | |||||
| val difference = selectedQty.subtract(bookQty) | |||||
| if (bookQty.compareTo(BigDecimal.ZERO) != 0) { | |||||
| return difference | |||||
| .divide(bookQty, 6, RoundingMode.HALF_UP) | |||||
| .multiply(BigDecimal("100")) | |||||
| } | |||||
| return when { | |||||
| difference.compareTo(BigDecimal.ZERO) == 0 -> BigDecimal.ZERO | |||||
| difference.compareTo(BigDecimal.ZERO) > 0 -> BigDecimal("100") | |||||
| else -> BigDecimal("-100") | |||||
| } | |||||
| } | |||||
| /** | |||||
| * @param varianceFilterInclusive false:只保留區間外(vp ≤ -t 或 vp ≥ t); | |||||
| * true:只保留區間內(-t ≤ vp ≤ t) | |||||
| * @param varianceFilterStrict true:邊界用 > <;false:用 ≥ ≤ | |||||
| */ | |||||
| private fun filterInventoryLotDetailsByVariancePercent( | |||||
| responses: List<InventoryLotDetailResponse>, | |||||
| variancePercentTolerance: BigDecimal?, | |||||
| varianceFilterInclusive: Boolean?, | |||||
| varianceFilterStrict: Boolean?, | |||||
| ): List<InventoryLotDetailResponse> { | |||||
| if (variancePercentTolerance == null) { | |||||
| return responses | |||||
| } | |||||
| val tolerance = variancePercentTolerance.abs() | |||||
| if (tolerance.compareTo(BigDecimal.ZERO) <= 0) { | |||||
| return responses | |||||
| } | |||||
| val inclusive = varianceFilterInclusive == true | |||||
| val strict = varianceFilterStrict == true | |||||
| val negTolerance = tolerance.negate() | |||||
| return responses.filter { response -> | |||||
| val vp = computeInventoryLotDetailVariancePercentage(response) | |||||
| if (inclusive) { | |||||
| if (strict) { | |||||
| vp > negTolerance && vp < tolerance | |||||
| } else { | |||||
| vp >= negTolerance && vp <= tolerance | |||||
| } | |||||
| } else { | |||||
| if (strict) { | |||||
| vp < negTolerance || vp > tolerance | |||||
| } else { | |||||
| vp <= negTolerance || vp >= tolerance | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun filterInventoryLotDetailsByItemName( | private fun filterInventoryLotDetailsByItemName( | ||||
| responses: List<InventoryLotDetailResponse>, | responses: List<InventoryLotDetailResponse>, | ||||
| itemName: String? | itemName: String? | ||||
| @@ -510,10 +576,13 @@ open class StockTakeRecordService( | |||||
| itemKeyword: String? = null, | itemKeyword: String? = null, | ||||
| sectionDescription: String? = null, | sectionDescription: String? = null, | ||||
| stockTakeSections: String? = null, | stockTakeSections: String? = null, | ||||
| warehouseKeyword: String? = null | |||||
| warehouseKeyword: String? = null, | |||||
| variancePercentTolerance: BigDecimal? = null, | |||||
| varianceFilterInclusive: Boolean? = null, | |||||
| varianceFilterStrict: Boolean? = null, | |||||
| ): RecordsRes<InventoryLotDetailResponse> { | ): RecordsRes<InventoryLotDetailResponse> { | ||||
| println("getApproverInventoryLotDetailsAll called with stockTakeId: $stockTakeId, pageNum: $pageNum, pageSize: $pageSize, itemKeyword: $itemKeyword, sectionDescription: $sectionDescription, stockTakeSections: $stockTakeSections, warehouseKeyword: $warehouseKeyword") | |||||
| println("getApproverInventoryLotDetailsAll called with stockTakeId: $stockTakeId, pageNum: $pageNum, pageSize: $pageSize, itemKeyword: $itemKeyword, sectionDescription: $sectionDescription, stockTakeSections: $stockTakeSections, warehouseKeyword: $warehouseKeyword, variancePercentTolerance: $variancePercentTolerance, varianceFilterInclusive: $varianceFilterInclusive, varianceFilterStrict: $varianceFilterStrict") | |||||
| // 3. 如果传了 stockTakeId,就把「同一轮」的所有 stockTake 找出来(stockTakeRoundId,舊資料則 planStart) | // 3. 如果传了 stockTakeId,就把「同一轮」的所有 stockTake 找出来(stockTakeRoundId,舊資料則 planStart) | ||||
| // stockTakeId != null 时,优先用 stocktakerecord.stockTakeRoundId 取整轮记录(更快) | // stockTakeId != null 时,优先用 stocktakerecord.stockTakeRoundId 取整轮记录(更快) | ||||
| @@ -720,19 +789,26 @@ open class StockTakeRecordService( | |||||
| } else { | } else { | ||||
| approvalFilteredResults | approvalFilteredResults | ||||
| } | } | ||||
| val varianceFilteredFinal = filterInventoryLotDetailsByVariancePercent( | |||||
| approvalFilteredFinal, | |||||
| variancePercentTolerance, | |||||
| varianceFilterInclusive, | |||||
| varianceFilterStrict, | |||||
| ) | |||||
| // 7. 分页(和 section 版一模一样) | // 7. 分页(和 section 版一模一样) | ||||
| val pageable = PageRequest.of(pageNum, pageSize) | val pageable = PageRequest.of(pageNum, pageSize) | ||||
| val startIndex = pageable.offset.toInt() | val startIndex = pageable.offset.toInt() | ||||
| val endIndex = minOf(startIndex + pageSize, approvalFilteredFinal.size) | |||||
| val endIndex = minOf(startIndex + pageSize, varianceFilteredFinal.size) | |||||
| val paginatedResult = if (startIndex < approvalFilteredFinal.size) { | |||||
| approvalFilteredFinal.subList(startIndex, endIndex) | |||||
| val paginatedResult = if (startIndex < varianceFilteredFinal.size) { | |||||
| varianceFilteredFinal.subList(startIndex, endIndex) | |||||
| } else { | } else { | ||||
| emptyList() | emptyList() | ||||
| } | } | ||||
| return RecordsRes(paginatedResult, approvalFilteredFinal.size) | |||||
| return RecordsRes(paginatedResult, varianceFilteredFinal.size) | |||||
| } | } | ||||
| open fun AllApproverStockTakeList(): List<AllPickedStockTakeListReponse> { | open fun AllApproverStockTakeList(): List<AllPickedStockTakeListReponse> { | ||||
| // Overall 卡:只取“最新一轮”,并且总数口径与 | // Overall 卡:只取“最新一轮”,并且总数口径与 | ||||
| @@ -1268,6 +1344,48 @@ open class StockTakeRecordService( | |||||
| return toSaveStockTakeRecordResponse(savedRecord) | return toSaveStockTakeRecordResponse(savedRecord) | ||||
| } | } | ||||
| /** | |||||
| * 拣货员一次提交多行已输入数量;每行复用 [saveStockTakeRecord],与单行保存一致。 | |||||
| */ | |||||
| @Transactional | |||||
| open fun batchSavePickerStockTakeInputs( | |||||
| request: BatchSavePickerStockTakeInputRequest | |||||
| ): BatchSaveStockTakeRecordResponse { | |||||
| if (request.records.isEmpty()) { | |||||
| return BatchSaveStockTakeRecordResponse( | |||||
| 0, | |||||
| 0, | |||||
| listOf("No records provided") | |||||
| ) | |||||
| } | |||||
| var successCount = 0 | |||||
| var errorCount = 0 | |||||
| val errors = mutableListOf<String>() | |||||
| request.records.forEach { item -> | |||||
| try { | |||||
| saveStockTakeRecord(item, request.stockTakeId, request.stockTakerId) | |||||
| successCount++ | |||||
| } catch (e: Exception) { | |||||
| errorCount++ | |||||
| val msg = "inventoryLotLineId ${item.inventoryLotLineId}: ${e.message ?: e.javaClass.simpleName}" | |||||
| errors.add(msg) | |||||
| logger.warn("batchSavePickerStockTakeInputs failed: $msg", e) | |||||
| } | |||||
| } | |||||
| if (successCount > 0 && request.stockTakeSection.isNotBlank()) { | |||||
| checkAndUpdateStockTakeStatus(request.stockTakeId, request.stockTakeSection) | |||||
| } | |||||
| return BatchSaveStockTakeRecordResponse( | |||||
| successCount = successCount, | |||||
| errorCount = errorCount, | |||||
| errors = errors | |||||
| ) | |||||
| } | |||||
| private fun toSaveStockTakeRecordResponse(r: StockTakeRecord): SaveStockTakeRecordResponse { | private fun toSaveStockTakeRecordResponse(r: StockTakeRecord): SaveStockTakeRecordResponse { | ||||
| return SaveStockTakeRecordResponse( | return SaveStockTakeRecordResponse( | ||||
| id = r.id, | id = r.id, | ||||
| @@ -1366,13 +1484,8 @@ open class StockTakeRecordService( | |||||
| // 使用 availableQty 作为 qty,badQty 为 0 | // 使用 availableQty 作为 qty,badQty 为 0 | ||||
| val qty = availableQty | val qty = availableQty | ||||
| val badQty = BigDecimal.ZERO | val badQty = BigDecimal.ZERO | ||||
| // 判断是否匹配 | |||||
| val totalInputQty = qty.add(badQty) | |||||
| val isMatched = totalInputQty.compareTo(availableQty) == 0 | |||||
| val varianceQty = availableQty - qty - badQty | val varianceQty = availableQty - qty - badQty | ||||
| val isCompleted = (ill.inQty ?: BigDecimal.ZERO) == (ill.outQty ?: BigDecimal.ZERO) | |||||
| // 创建新记录 | |||||
| // 拣货员第一次批量盘点:与单笔第一次保存一致,一律 pass;不在此写 stockTakeEndTime(仅第二次盘点结束) | |||||
| val stockTakeRecord = StockTakeRecord().apply { | val stockTakeRecord = StockTakeRecord().apply { | ||||
| this.itemId = itemEntity.id | this.itemId = itemEntity.id | ||||
| this.lotId = inventoryLot.id | this.lotId = inventoryLot.id | ||||
| @@ -1389,14 +1502,13 @@ open class StockTakeRecordService( | |||||
| this.varianceQty = varianceQty | this.varianceQty = varianceQty | ||||
| this.uom = ill.stockUom?.uom?.udfudesc | this.uom = ill.stockUom?.uom?.udfudesc | ||||
| this.date = java.time.LocalDate.now() | this.date = java.time.LocalDate.now() | ||||
| this.status = if (isCompleted) "completed" else "pass" | |||||
| this.status = "pass" | |||||
| this.remarks = null | this.remarks = null | ||||
| this.itemCode = itemEntity.code | this.itemCode = itemEntity.code | ||||
| this.itemName = itemEntity.name | this.itemName = itemEntity.name | ||||
| this.lotNo = inventoryLot.lotNo | this.lotNo = inventoryLot.lotNo | ||||
| this.expiredDate = inventoryLot.expiryDate | this.expiredDate = inventoryLot.expiryDate | ||||
| this.stockTakeStartTime = java.time.LocalDateTime.now() | this.stockTakeStartTime = java.time.LocalDateTime.now() | ||||
| this.stockTakeEndTime = java.time.LocalDateTime.now() | |||||
| } | } | ||||
| stockTakeRecordRepository.save(stockTakeRecord) | stockTakeRecordRepository.save(stockTakeRecord) | ||||
| @@ -64,6 +64,7 @@ open class StockTakeService( | |||||
| request.remarks?.let { stockTake.remarks = it } | request.remarks?.let { stockTake.remarks = it } | ||||
| request.stockTakeSection?.let { stockTake.stockTakeSection = it } // 添加此行 | request.stockTakeSection?.let { stockTake.stockTakeSection = it } // 添加此行 | ||||
| request.stockTakeRoundId?.let { stockTake.stockTakeRoundId = it } | request.stockTakeRoundId?.let { stockTake.stockTakeRoundId = it } | ||||
| request.stockTakeRoundName?.let { stockTake.stockTakeRoundName = it } | |||||
| return stockTakeRepository.save(stockTake) | return stockTakeRepository.save(stockTake) | ||||
| } | } | ||||
| @@ -239,23 +240,43 @@ open class StockTakeService( | |||||
| fun createStockTakeForSections(): Map<String, String> { | |||||
| fun createStockTakeForSections( | |||||
| sectionsFilter: List<String>? = null, | |||||
| stockTakeRoundName: String? = null, | |||||
| planStart: LocalDateTime? = null, | |||||
| ): Map<String, String> { | |||||
| log.info("--------- Start - Create Stock Take for Sections -------") | log.info("--------- Start - Create Stock Take for Sections -------") | ||||
| val result = mutableMapOf<String, String>() | val result = mutableMapOf<String, String>() | ||||
| // 1. 获取所有不同的 stockTakeSection(从 warehouse 表) | // 1. 获取所有不同的 stockTakeSection(从 warehouse 表) | ||||
| val allWarehouses = warehouseRepository.findAllByDeletedIsFalse() | val allWarehouses = warehouseRepository.findAllByDeletedIsFalse() | ||||
| val distinctSections = allWarehouses | |||||
| var distinctSections = allWarehouses | |||||
| .mapNotNull { it.stockTakeSection } | .mapNotNull { it.stockTakeSection } | ||||
| .distinct() | .distinct() | ||||
| .filter { !it.isBlank() } | .filter { !it.isBlank() } | ||||
| if (sectionsFilter != null) { | |||||
| if (sectionsFilter.isEmpty()) { | |||||
| log.warn("Create stock take: empty section list in request") | |||||
| return mapOf("_" to "Error: No stock take sections selected") | |||||
| } | |||||
| val wanted = sectionsFilter.map { it.trim() }.filter { it.isNotBlank() }.toSet() | |||||
| distinctSections = distinctSections.filter { section -> | |||||
| wanted.any { w -> section.equals(w, ignoreCase = true) } | |||||
| } | |||||
| if (distinctSections.isEmpty()) { | |||||
| log.warn("Create stock take: no warehouse sections matched filter") | |||||
| return mapOf("_" to "Error: No matching warehouse stock take sections") | |||||
| } | |||||
| } | |||||
| // 移除 null section 处理逻辑,因为 warehouse 表中没有 null 的 stockTakeSection | // 移除 null section 处理逻辑,因为 warehouse 表中没有 null 的 stockTakeSection | ||||
| val batchPlanStart = LocalDateTime.now() | |||||
| val batchPlanStart = planStart ?: LocalDateTime.now() | |||||
| val batchPlanEnd = batchPlanStart.plusDays(1) | val batchPlanEnd = batchPlanStart.plusDays(1) | ||||
| // 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4) | // 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4) | ||||
| val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 | val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 | ||||
| val roundName = stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() } | |||||
| var placeholderStockTakerId: Long? = null | var placeholderStockTakerId: Long? = null | ||||
| distinctSections.forEach { section -> | distinctSections.forEach { section -> | ||||
| try { | try { | ||||
| @@ -269,7 +290,8 @@ open class StockTakeService( | |||||
| status = StockTakeStatus.PENDING.value, | status = StockTakeStatus.PENDING.value, | ||||
| remarks = null, | remarks = null, | ||||
| stockTakeSection = section, | stockTakeSection = section, | ||||
| stockTakeRoundId = roundId | |||||
| stockTakeRoundId = roundId, | |||||
| stockTakeRoundName = roundName, | |||||
| ) | ) | ||||
| val savedStockTake = saveStockTake(saveStockTakeReq) | val savedStockTake = saveStockTake(saveStockTakeReq) | ||||
| if (placeholderStockTakerId == null) { | if (placeholderStockTakerId == null) { | ||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.stock.web | package com.ffii.fpsms.modules.stock.web | ||||
| import com.ffii.fpsms.modules.stock.service.StockTakeService | import com.ffii.fpsms.modules.stock.service.StockTakeService | ||||
| import com.ffii.fpsms.modules.stock.web.model.CreateStockTakeForSectionsRequest | |||||
| import jakarta.servlet.http.HttpServletRequest | import jakarta.servlet.http.HttpServletRequest | ||||
| import org.apache.poi.ss.usermodel.Workbook | import org.apache.poi.ss.usermodel.Workbook | ||||
| import org.apache.poi.util.IOUtils | import org.apache.poi.util.IOUtils | ||||
| @@ -9,6 +10,7 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||||
| import org.springframework.http.ResponseEntity | import org.springframework.http.ResponseEntity | ||||
| import org.springframework.web.bind.ServletRequestBindingException | import org.springframework.web.bind.ServletRequestBindingException | ||||
| import org.springframework.web.bind.annotation.PostMapping | import org.springframework.web.bind.annotation.PostMapping | ||||
| import org.springframework.web.bind.annotation.RequestBody | |||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||
| import org.springframework.web.multipart.MultipartHttpServletRequest | import org.springframework.web.multipart.MultipartHttpServletRequest | ||||
| @@ -41,8 +43,25 @@ class StockTakeController( | |||||
| return ResponseEntity.ok(stockTakeService.importExcel(workbook)) | return ResponseEntity.ok(stockTakeService.importExcel(workbook)) | ||||
| } | } | ||||
| @PostMapping("/createForSections") | @PostMapping("/createForSections") | ||||
| fun createStockTakeForSections(): ResponseEntity<Map<String, String>> { | |||||
| val result = stockTakeService.createStockTakeForSections() | |||||
| fun createStockTakeForSections( | |||||
| @RequestBody(required = false) body: CreateStockTakeForSectionsRequest?, | |||||
| ): ResponseEntity<Map<String, String>> { | |||||
| val trimmed = | |||||
| body?.sections | |||||
| ?.map { it.trim() } | |||||
| ?.filter { it.isNotEmpty() } | |||||
| val sectionsFilter: List<String>? = | |||||
| when { | |||||
| body == null -> null | |||||
| trimmed == null -> null | |||||
| trimmed.isEmpty() -> emptyList() | |||||
| else -> trimmed | |||||
| } | |||||
| val result = stockTakeService.createStockTakeForSections( | |||||
| sectionsFilter = sectionsFilter, | |||||
| stockTakeRoundName = body?.stockTakeRoundName, | |||||
| planStart = body?.planStart, | |||||
| ) | |||||
| return ResponseEntity.ok(result) | return ResponseEntity.ok(result) | ||||
| } | } | ||||
| @@ -117,7 +117,10 @@ class StockTakeRecordController( | |||||
| @RequestParam(required = false) itemKeyword: String?, | @RequestParam(required = false) itemKeyword: String?, | ||||
| @RequestParam(required = false) sectionDescription: String?, | @RequestParam(required = false) sectionDescription: String?, | ||||
| @RequestParam(required = false) stockTakeSections: String?, | @RequestParam(required = false) stockTakeSections: String?, | ||||
| @RequestParam(required = false) warehouseKeyword: String? | |||||
| @RequestParam(required = false) warehouseKeyword: String?, | |||||
| @RequestParam(required = false) variancePercentTolerance: java.math.BigDecimal?, | |||||
| @RequestParam(required = false) varianceFilterInclusive: Boolean?, | |||||
| @RequestParam(required = false) varianceFilterStrict: Boolean?, | |||||
| ): RecordsRes<InventoryLotDetailResponse> { | ): RecordsRes<InventoryLotDetailResponse> { | ||||
| return stockOutRecordService.getApproverInventoryLotDetailsAll( | return stockOutRecordService.getApproverInventoryLotDetailsAll( | ||||
| stockTakeId = stockTakeId, | stockTakeId = stockTakeId, | ||||
| @@ -127,7 +130,10 @@ class StockTakeRecordController( | |||||
| itemKeyword = itemKeyword, | itemKeyword = itemKeyword, | ||||
| sectionDescription = sectionDescription, | sectionDescription = sectionDescription, | ||||
| stockTakeSections = stockTakeSections, | stockTakeSections = stockTakeSections, | ||||
| warehouseKeyword = warehouseKeyword | |||||
| warehouseKeyword = warehouseKeyword, | |||||
| variancePercentTolerance = variancePercentTolerance, | |||||
| varianceFilterInclusive = varianceFilterInclusive, | |||||
| varianceFilterStrict = varianceFilterStrict, | |||||
| ) | ) | ||||
| } | } | ||||
| @GetMapping("/approverInventoryLotDetailsAllApproved") | @GetMapping("/approverInventoryLotDetailsAllApproved") | ||||
| @@ -138,7 +144,10 @@ class StockTakeRecordController( | |||||
| @RequestParam(required = false) itemKeyword: String?, | @RequestParam(required = false) itemKeyword: String?, | ||||
| @RequestParam(required = false) sectionDescription: String?, | @RequestParam(required = false) sectionDescription: String?, | ||||
| @RequestParam(required = false) stockTakeSections: String?, | @RequestParam(required = false) stockTakeSections: String?, | ||||
| @RequestParam(required = false) warehouseKeyword: String? | |||||
| @RequestParam(required = false) warehouseKeyword: String?, | |||||
| @RequestParam(required = false) variancePercentTolerance: java.math.BigDecimal?, | |||||
| @RequestParam(required = false) varianceFilterInclusive: Boolean?, | |||||
| @RequestParam(required = false) varianceFilterStrict: Boolean?, | |||||
| ): RecordsRes<InventoryLotDetailResponse> { | ): RecordsRes<InventoryLotDetailResponse> { | ||||
| return stockOutRecordService.getApproverInventoryLotDetailsAll( | return stockOutRecordService.getApproverInventoryLotDetailsAll( | ||||
| stockTakeId = stockTakeId, | stockTakeId = stockTakeId, | ||||
| @@ -148,7 +157,10 @@ class StockTakeRecordController( | |||||
| itemKeyword = itemKeyword, | itemKeyword = itemKeyword, | ||||
| sectionDescription = sectionDescription, | sectionDescription = sectionDescription, | ||||
| stockTakeSections = stockTakeSections, | stockTakeSections = stockTakeSections, | ||||
| warehouseKeyword = warehouseKeyword | |||||
| warehouseKeyword = warehouseKeyword, | |||||
| variancePercentTolerance = variancePercentTolerance, | |||||
| varianceFilterInclusive = varianceFilterInclusive, | |||||
| varianceFilterStrict = varianceFilterStrict, | |||||
| ) | ) | ||||
| } | } | ||||
| @GetMapping("/AllApproverStockTakeList") | @GetMapping("/AllApproverStockTakeList") | ||||
| @@ -250,6 +262,31 @@ class StockTakeRecordController( | |||||
| )) | )) | ||||
| } | } | ||||
| } | } | ||||
| @PostMapping("/batchSavePickerStockTakeInputs") | |||||
| fun batchSavePickerStockTakeInputs( | |||||
| @RequestBody request: BatchSavePickerStockTakeInputRequest | |||||
| ): ResponseEntity<Any> { | |||||
| return try { | |||||
| val result = stockOutRecordService.batchSavePickerStockTakeInputs(request) | |||||
| logger.info( | |||||
| "Batch save picker inputs: success=${result.successCount}, errors=${result.errorCount}" | |||||
| ) | |||||
| ResponseEntity.ok(result) | |||||
| } catch (e: IllegalArgumentException) { | |||||
| logger.warn("Validation error: ${e.message}") | |||||
| ResponseEntity.badRequest().body(mapOf( | |||||
| "error" to "VALIDATION_ERROR", | |||||
| "message" to (e.message ?: "Validation failed") | |||||
| )) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Error batch saving picker stock take inputs", e) | |||||
| ResponseEntity.status(500).body(mapOf( | |||||
| "error" to "INTERNAL_ERROR", | |||||
| "message" to (e.message ?: "Failed to batch save picker stock take inputs") | |||||
| )) | |||||
| } | |||||
| } | |||||
| @GetMapping("/checkAndUpdateStockTakeStatus") | @GetMapping("/checkAndUpdateStockTakeStatus") | ||||
| fun checkAndUpdateStockTakeStatus( | fun checkAndUpdateStockTakeStatus( | ||||
| @@ -1,8 +1,11 @@ | |||||
| package com.ffii.fpsms.modules.stock.web.model | package com.ffii.fpsms.modules.stock.web.model | ||||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties | import com.fasterxml.jackson.annotation.JsonIgnoreProperties | ||||
| import java.time.LocalDateTime | |||||
| @JsonIgnoreProperties(ignoreUnknown = true) | @JsonIgnoreProperties(ignoreUnknown = true) | ||||
| data class CreateStockTakeForSectionsRequest( | data class CreateStockTakeForSectionsRequest( | ||||
| val sections: List<String>? = null, | val sections: List<String>? = null, | ||||
| val stockTakeRoundName: String? = null, | |||||
| val planStart: LocalDateTime? = null, | |||||
| ) | ) | ||||
| @@ -13,4 +13,5 @@ data class SaveStockTakeRequest( | |||||
| val remarks: String?, | val remarks: String?, | ||||
| val stockTakeSection: String?=null, | val stockTakeSection: String?=null, | ||||
| val stockTakeRoundId: Long? = null, | val stockTakeRoundId: Long? = null, | ||||
| val stockTakeRoundName: String? = null, | |||||
| ) | ) | ||||
| @@ -123,6 +123,14 @@ data class BatchSaveStockTakeRecordRequest( | |||||
| //val stockTakerName: String | //val stockTakerName: String | ||||
| ) | ) | ||||
| /** 拣货员批量提交已输入行(与 [saveStockTakeRecord] 相同业务,逐条保存用户 qty/badQty)。 */ | |||||
| data class BatchSavePickerStockTakeInputRequest( | |||||
| val stockTakeId: Long, | |||||
| val stockTakeSection: String, | |||||
| val stockTakerId: Long, | |||||
| val records: List<SaveStockTakeRecordRequest>, | |||||
| ) | |||||
| data class BatchSaveStockTakeRecordResponse( | data class BatchSaveStockTakeRecordResponse( | ||||
| val successCount: Int, | val successCount: Int, | ||||
| val errorCount: Int, | val errorCount: Int, | ||||
| @@ -0,0 +1,5 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset Enson:20260519-05-stock-take-round-name | |||||
| ALTER TABLE stock_take | |||||
| ADD COLUMN stockTakeRoundName VARCHAR(255); | |||||