| @@ -33,6 +33,7 @@ import java.util.UUID | |||
| import java.util.Comparator | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | |||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | |||
| import org.springframework.transaction.annotation.Transactional | |||
| @Service | |||
| open class BomService( | |||
| @@ -158,6 +159,265 @@ open class BomService( | |||
| return response | |||
| } | |||
| /** | |||
| * Edit BOM (basic fields + BOM materials + BOM process lines). | |||
| * | |||
| * Key rules: | |||
| * 1) baseScore is server-calculated and cannot be edited by client. | |||
| * 2) Input existence will be validated (BOM/Item/Process/Equipment/UOM settings). | |||
| * 3) When `materials` or `processes` is provided, we rebuild BomProcessMaterial mappings | |||
| * using current BOM materials & process lines (default: link every material to every process line). | |||
| */ | |||
| @Transactional | |||
| open fun editBom(id: Long, request: EditBomRequest): BomDetailResponse { | |||
| val bom = bomRepository.findByIdAndDeletedIsFalse(id) | |||
| ?: throw NotFoundException() | |||
| // ---- update basic/score fields (only when provided) ---- | |||
| request.description?.let { bom.description = it } | |||
| request.outputQty?.let { bom.outputQty = it } | |||
| request.outputQtyUom?.let { bom.outputQtyUom = it } | |||
| request.yield?.let { bom.yield = it } | |||
| request.isDark?.let { bom.isDark = it } | |||
| request.isFloat?.let { bom.isFloat = it } | |||
| request.isDense?.let { bom.isDense = it } | |||
| request.scrapRate?.let { bom.scrapRate = it } | |||
| request.allergicSubstances?.let { bom.allergicSubstances = it } | |||
| request.timeSequence?.let { bom.timeSequence = it } | |||
| request.complexity?.let { bom.complexity = it } | |||
| request.isDrink?.let { bom.isDrink = it } | |||
| val replaceMaterials = request.materials != null | |||
| val replaceProcesses = request.processes != null | |||
| if (!replaceMaterials && !replaceProcesses) { | |||
| // Only basic/score fields changed: just recalc. | |||
| bom.baseScore = calculateBaseScore(bom) | |||
| bomRepository.saveAndFlush(bom) | |||
| return getBomDetail(bom.id!!) | |||
| } | |||
| // ---- pre-clean BomProcessMaterial to avoid FK issues when clearing/recreating children ---- | |||
| fun deleteAllBomProcessMaterialsForCurrentProcesses() { | |||
| val processIds = bom.bomProcesses.mapNotNull { it.id } | |||
| if (processIds.isNotEmpty()) { | |||
| val existing = bomProcessMaterialRepository.findByBomProcess_IdIn(processIds) | |||
| if (existing.isNotEmpty()) { | |||
| bomProcessMaterialRepository.deleteAll(existing) | |||
| } | |||
| } | |||
| } | |||
| if (replaceMaterials) { | |||
| deleteAllBomProcessMaterialsForCurrentProcesses() | |||
| bom.bomMaterials.clear() | |||
| request.materials?.forEach { mReq -> | |||
| val item = when { | |||
| mReq.itemId != null -> itemsRepository.findByIdAndDeletedFalse(mReq.itemId) ?: throw IllegalArgumentException("Item not found: id=${mReq.itemId}") | |||
| !mReq.itemCode.isNullOrBlank() -> itemsRepository.findByCodeAndDeletedFalse(mReq.itemCode.trim()) ?: throw IllegalArgumentException("Item not found: code=${mReq.itemCode}") | |||
| else -> throw IllegalArgumentException("Material requires itemId or itemCode") | |||
| } | |||
| val baseItemUom = itemUomService.findBaseUnitByItemId(item.id!!) | |||
| ?: throw IllegalArgumentException("Item base unit not configured: itemId=${item.id}") | |||
| val stockItemUom = itemUomService.findStockUnitByItemId(item.id!!) | |||
| ?: throw IllegalArgumentException("Item stock unit not configured: itemId=${item.id}") | |||
| val salesItemUom = itemUomService.findSalesUnitByItemId(item.id!!) | |||
| ?: throw IllegalArgumentException("Item sales unit not configured: itemId=${item.id}") | |||
| val baseUom = baseItemUom.uom ?: throw IllegalArgumentException("Base UOM conversion not configured: itemId=${item.id}") | |||
| val stockUom = stockItemUom.uom ?: throw IllegalArgumentException("Stock UOM conversion not configured: itemId=${item.id}") | |||
| val salesUom = salesItemUom.uom ?: throw IllegalArgumentException("Sales UOM conversion not configured: itemId=${item.id}") | |||
| val baseQty = mReq.qty | |||
| val stockQty = itemUomService.convertUomByItem( | |||
| ConvertUomByItemRequest( | |||
| itemId = item.id!!, | |||
| qty = baseQty, | |||
| uomId = baseUom.id, | |||
| targetUnit = "stockUnit" | |||
| ) | |||
| ).newQty | |||
| val salesConverted = itemUomService.convertUomByItem( | |||
| ConvertUomByItemRequest( | |||
| itemId = item.id!!, | |||
| qty = baseQty, | |||
| uomId = baseUom.id, | |||
| targetUnit = "salesUnit" | |||
| ) | |||
| ) | |||
| val salesQty = salesConverted.newQty | |||
| val stockUnitName = stockItemUom.uom?.udfudesc | |||
| val baseUnitName = baseItemUom.uom?.udfudesc | |||
| val salesUnitCode = salesConverted.udfudesc | |||
| val bomMaterial = BomMaterial().apply { | |||
| this.item = item | |||
| this.itemName = item.name | |||
| this.isConsumable = mReq.isConsumable ?: false | |||
| // BOM unit fields: derive from item's base unit. | |||
| this.qty = baseQty | |||
| this.uom = baseUom | |||
| this.uomName = baseUnitName | |||
| // Derived UOMs for display/logic. | |||
| this.saleQty = salesQty | |||
| this.salesUnit = salesUom | |||
| this.salesUnitCode = salesUnitCode | |||
| this.baseQty = baseQty | |||
| this.baseUnit = baseUom.id!!.toInt() | |||
| .let { Integer.valueOf(it) } as? Integer | |||
| this.baseUnitName = baseUnitName | |||
| this.stockQty = stockQty | |||
| this.stockUnit = stockUom.id!!.toInt() | |||
| .let { Integer.valueOf(it) } as? Integer | |||
| this.stockUnitName = stockUnitName | |||
| this.bom = bom | |||
| } | |||
| bom.bomMaterials.add(bomMaterial) | |||
| } | |||
| } | |||
| if (replaceProcesses) { | |||
| deleteAllBomProcessMaterialsForCurrentProcesses() | |||
| bom.bomProcesses.clear() | |||
| val maxSeqNoFromProvided = | |||
| request.processes?.mapNotNull { it.seqNo }?.maxOrNull() ?: 0L | |||
| var nextSeqNo = maxSeqNoFromProvided + 1 | |||
| // ---- seqNo uniqueness validation (when provided) ---- | |||
| val seqCounts = request.processes | |||
| ?.mapNotNull { it.seqNo } | |||
| ?.groupingBy { it } | |||
| ?.eachCount() | |||
| ?: emptyMap() | |||
| val duplicates = seqCounts.filter { it.value > 1 }.keys | |||
| if (duplicates.isNotEmpty()) { | |||
| throw IllegalArgumentException("Duplicate process seqNo: ${duplicates.joinToString(",")}") | |||
| } | |||
| request.processes?.forEach { pReq -> | |||
| val process = when { | |||
| pReq.processId != null -> processRepository.findById(pReq.processId).orElse(null) | |||
| !pReq.processCode.isNullOrBlank() -> processRepository.findByCodeAndDeletedIsFalse(pReq.processCode.trim()) | |||
| else -> null | |||
| } ?: throw IllegalArgumentException("Process not found: processId=${pReq.processId}, processCode=${pReq.processCode}") | |||
| if (process.deleted == true) { | |||
| throw IllegalArgumentException("Process is deleted: id=${process.id}") | |||
| } | |||
| val equipment = resolveEquipmentForBomProcess(pReq) | |||
| val seqNo = pReq.seqNo ?: nextSeqNo++ | |||
| val description = pReq.description ?: "" | |||
| val bomProcess = BomProcess().apply { | |||
| this.process = process | |||
| this.equipment = equipment | |||
| this.description = description | |||
| this.seqNo = seqNo | |||
| this.durationInMinute = pReq.durationInMinute ?: 0 | |||
| this.prepTimeInMinute = pReq.prepTimeInMinute ?: 0 | |||
| this.postProdTimeInMinute = pReq.postProdTimeInMinute ?: 0 | |||
| this.bom = bom | |||
| } | |||
| bom.bomProcesses.add(bomProcess) | |||
| } | |||
| } | |||
| // ---- persist children first (so ids exist) ---- | |||
| bomRepository.saveAndFlush(bom) | |||
| // ---- rebuild BomProcessMaterial mappings when children changed ---- | |||
| if (replaceMaterials || replaceProcesses) { | |||
| val processIds = bom.bomProcesses.mapNotNull { it.id } | |||
| if (processIds.isNotEmpty()) { | |||
| val existing = bomProcessMaterialRepository.findByBomProcess_IdIn(processIds) | |||
| if (existing.isNotEmpty()) { | |||
| bomProcessMaterialRepository.deleteAll(existing) | |||
| } | |||
| } | |||
| val mappings = mutableListOf<BomProcessMaterial>() | |||
| bom.bomProcesses.forEach { proc -> | |||
| bom.bomMaterials.forEach { mat -> | |||
| mappings.add( | |||
| BomProcessMaterial().apply { | |||
| this.bomProcess = proc | |||
| this.bomMaterial = mat | |||
| } | |||
| ) | |||
| } | |||
| } | |||
| if (mappings.isNotEmpty()) { | |||
| bomProcessMaterialRepository.saveAll(mappings) | |||
| } | |||
| } | |||
| // ---- baseScore recalculation (server controlled) ---- | |||
| bom.baseScore = calculateBaseScore(bom) | |||
| bomRepository.saveAndFlush(bom) | |||
| return getBomDetail(bom.id!!) | |||
| } | |||
| private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment { | |||
| val equipmentId = pReq.equipmentId | |||
| val equipmentCode = pReq.equipmentCode?.trim().orEmpty() | |||
| val found = when { | |||
| equipmentId != null -> equipmentRepository.findByIdAndDeletedFalse(equipmentId) | |||
| equipmentCode.isNotBlank() -> equipmentRepository.findByCodeAndDeletedFalse(equipmentCode) | |||
| ?: equipmentRepository.findByNameAndDeletedIsFalse(equipmentCode) | |||
| else -> null | |||
| } | |||
| if (found != null) return found | |||
| // Create new equipment on-demand (optional) | |||
| if (pReq.newEquipment != null) { | |||
| val req = pReq.newEquipment | |||
| val duplicated = equipmentRepository.findByCodeAndDeletedIsFalse(req.code.trim()) | |||
| if (duplicated != null) { | |||
| throw IllegalArgumentException("Equipment code already exists: code=${req.code}") | |||
| } | |||
| val equipmentDetail = req.equipmentTypeId?.let { equipmentDetailRepository.findByIdAndDeletedFalse(it) } | |||
| val entity = Equipment().apply { | |||
| code = req.code.trim() | |||
| name = req.name.trim() | |||
| description = req.description?.trim().orEmpty() | |||
| this.EquipmentDetail = equipmentDetail | |||
| deleted = false | |||
| } | |||
| return equipmentRepository.saveAndFlush(entity) | |||
| } | |||
| // Fallback: create a default placeholder equipment to satisfy non-null FK. | |||
| val placeholderCode = if (equipmentCode.isNotBlank()) equipmentCode else "不適用" | |||
| return equipmentRepository.findByCodeAndDeletedIsFalse(placeholderCode) | |||
| ?: equipmentRepository.findByNameAndDeletedIsFalse(placeholderCode) | |||
| ?: equipmentRepository.saveAndFlush( | |||
| Equipment().apply { | |||
| code = placeholderCode | |||
| name = placeholderCode | |||
| description = placeholderCode | |||
| deleted = false | |||
| } | |||
| ) | |||
| } | |||
| //////// -------------------------------- for excel import ------------------------------- ///////// | |||
| private fun saveBomEntity(req: ImportBomRequest): Bom { | |||
| val item = itemsRepository.findByCodeAndDeletedFalse(req.code) ?: itemsRepository.findByNameAndDeletedFalse(req.name) | |||
| @@ -2544,6 +2804,7 @@ println("=====================================") | |||
| BomMaterialDto( | |||
| itemCode = m.item?.code, | |||
| itemName = m.item?.name ?: m.itemName, | |||
| isConsumable = m.isConsumable, | |||
| baseQty = m.baseQty, | |||
| baseUom = m.baseUnitName, | |||
| stockQty = m.stockQty, | |||
| @@ -2558,8 +2819,10 @@ println("=====================================") | |||
| .map { p -> | |||
| BomProcessDto( | |||
| seqNo = p.seqNo, | |||
| processCode = p.process?.code, | |||
| processName = p.process?.name, | |||
| processDescription = p.description, | |||
| equipmentCode = p.equipment?.code, | |||
| equipmentName = p.equipment?.name, | |||
| durationInMinute = p.durationInMinute, | |||
| prepTimeInMinute = p.prepTimeInMinute, | |||
| @@ -11,6 +11,7 @@ import org.springframework.http.ResponseEntity | |||
| import org.springframework.web.bind.annotation.GetMapping | |||
| import org.springframework.web.bind.annotation.PathVariable | |||
| import org.springframework.web.bind.annotation.PostMapping | |||
| import org.springframework.web.bind.annotation.PutMapping | |||
| import org.springframework.web.bind.annotation.RequestBody | |||
| import org.springframework.web.bind.annotation.RequestMapping | |||
| import org.springframework.web.bind.annotation.RequestParam | |||
| @@ -26,6 +27,7 @@ import com.ffii.fpsms.modules.master.web.models.BomUploadResponse | |||
| import com.ffii.fpsms.modules.master.web.models.BomFormatCheckRequest | |||
| import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload | |||
| import com.ffii.fpsms.modules.master.web.models.BomDetailResponse | |||
| import com.ffii.fpsms.modules.master.web.models.EditBomRequest | |||
| import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | |||
| import java.util.logging.Logger | |||
| import java.nio.file.Files | |||
| @@ -122,4 +124,16 @@ fun downloadBomFormatIssueLog( | |||
| fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | |||
| return bomService.getBomDetail(id) | |||
| } | |||
| /** | |||
| * Edit BOM (basic fields + materials + process lines). | |||
| * baseScore is recalculated on server. | |||
| */ | |||
| @PutMapping("/{id}") | |||
| fun editBom( | |||
| @PathVariable id: Long, | |||
| @RequestBody request: EditBomRequest, | |||
| ): BomDetailResponse { | |||
| return bomService.editBom(id, request) | |||
| } | |||
| } | |||
| @@ -0,0 +1,90 @@ | |||
| package com.ffii.fpsms.modules.master.web.models | |||
| import com.ffii.fpsms.modules.master.entity.Equipment | |||
| import jakarta.validation.constraints.NotBlank | |||
| import jakarta.validation.constraints.NotNull | |||
| import java.math.BigDecimal | |||
| /** | |||
| * BOM edit request. | |||
| * | |||
| * Notes: | |||
| * - baseScore is NOT editable by client; server recalculates it after update. | |||
| * - When `materials` or `processes` is provided, we will rebuild BOMProcessMaterial mappings | |||
| * using current BOM materials & process lines (default: link every material to every process line). | |||
| */ | |||
| data class EditBomRequest( | |||
| // basic fields | |||
| val description: String? = null, | |||
| val outputQty: BigDecimal? = null, | |||
| val outputQtyUom: String? = null, | |||
| val yield: BigDecimal? = null, | |||
| // score-related fields (baseScore inputs) | |||
| val isDark: Int? = null, | |||
| val isFloat: Int? = null, | |||
| val isDense: Int? = null, | |||
| val scrapRate: Int? = null, | |||
| val allergicSubstances: Int? = null, | |||
| val timeSequence: Int? = null, | |||
| val complexity: Int? = null, | |||
| val isDrink: Boolean? = null, | |||
| // children | |||
| val materials: List<EditBomMaterialRequest>? = null, | |||
| val processes: List<EditBomProcessRequest>? = null, | |||
| ) | |||
| data class EditBomMaterialRequest( | |||
| val id: Long? = null, | |||
| // At least one of itemId/itemCode is required. | |||
| val itemId: Long? = null, | |||
| val itemCode: String? = null, | |||
| /** | |||
| * BOM qty input. | |||
| * We treat it as "base unit qty" for the item (auto derive base/stock/sales units by item UOM settings). | |||
| */ | |||
| @field:NotNull | |||
| val qty: BigDecimal, | |||
| val isConsumable: Boolean? = null, | |||
| ) | |||
| data class EditBomProcessRequest( | |||
| val id: Long? = null, | |||
| // If seqNo is null, we will assign next seqNo. | |||
| val seqNo: Long? = null, | |||
| // At least one of processId/processCode is required. | |||
| val processId: Long? = null, | |||
| val processCode: String? = null, | |||
| // Select existing equipment by id/code. | |||
| val equipmentId: Long? = null, | |||
| val equipmentCode: String? = null, | |||
| // If you need to create equipment on the fly, provide this. | |||
| val newEquipment: NewEquipmentForBomProcessRequest? = null, | |||
| val description: String? = null, | |||
| val durationInMinute: Int? = null, | |||
| val prepTimeInMinute: Int? = null, | |||
| val postProdTimeInMinute: Int? = null, | |||
| ) | |||
| data class NewEquipmentForBomProcessRequest( | |||
| @field:NotBlank | |||
| val code: String, | |||
| @field:NotBlank | |||
| val name: String, | |||
| val description: String? = null, | |||
| /** | |||
| * Optional EquipmentDetail.id used as Equipment.EquipmentDetail. | |||
| * If null, equipmentType is left empty. | |||
| */ | |||
| val equipmentTypeId: Long? = null, | |||
| ) | |||
| @@ -73,6 +73,7 @@ data class ImportBomRequestPayload( | |||
| data class BomMaterialDto( | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| val isConsumable: Boolean?, | |||
| val baseQty: BigDecimal?, | |||
| val baseUom: String?, | |||
| val stockQty: BigDecimal?, | |||
| @@ -84,9 +85,12 @@ data class BomMaterialDto( | |||
| data class BomProcessDto( | |||
| val seqNo: Long?, | |||
| val processCode: String?, | |||
| val processName: String?, | |||
| val processDescription: String?, | |||
| val equipmentName: String?, | |||
| // For display we prefer equipment.code (stable identifier). Keep equipmentName for backward compatibility. | |||
| val equipmentCode: String? = null, | |||
| val equipmentName: String? = null, | |||
| val durationInMinute: Int?, | |||
| val prepTimeInMinute: Int?, | |||
| val postProdTimeInMinute: Int? | |||
| @@ -71,6 +71,17 @@ interface InventoryLotLineRepository : AbstractRepository<InventoryLotLine, Long | |||
| @Query("SELECT ill FROM InventoryLotLine ill WHERE ill.warehouse.id IN :warehouseIds AND ill.deleted = false") | |||
| fun findAllByWarehouseIdInAndDeletedIsFalse(@Param("warehouseIds") warehouseIds: List<Long>): List<InventoryLotLine> | |||
| @Query(""" | |||
| SELECT ill FROM InventoryLotLine ill | |||
| WHERE ill.deleted = false | |||
| AND ill.warehouse.id IN :warehouseIds | |||
| AND ill.inventoryLot.id IN :inventoryLotIds | |||
| """) | |||
| fun findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse( | |||
| @Param("warehouseIds") warehouseIds: Collection<Long>, | |||
| @Param("inventoryLotIds") inventoryLotIds: Collection<Long> | |||
| ): List<InventoryLotLine> | |||
| @Query(""" | |||
| SELECT ill FROM InventoryLotLine ill | |||
| WHERE ill.warehouse.code = :warehouseCode | |||
| @@ -223,24 +223,11 @@ class StockTakeRecordService( | |||
| open fun getApproverInventoryLotDetailsAll( | |||
| stockTakeId: Long? = null, | |||
| pageNum: Int = 0, | |||
| pageSize: Int = 100 | |||
| pageSize: Int = 100, | |||
| approvalView: String? = null | |||
| ): RecordsRes<InventoryLotDetailResponse> { | |||
| println("getApproverInventoryLotDetailsAll called with stockTakeId: $stockTakeId, pageNum: $pageNum, pageSize: $pageSize") | |||
| // 1. 不分 section,直接拿所有未删除的 warehouse | |||
| val warehouses = warehouseRepository.findAllByDeletedIsFalse() | |||
| if (warehouses.isEmpty()) { | |||
| logger.warn("No warehouses found for approverInventoryLotDetailsAll") | |||
| return RecordsRes(emptyList(), 0) | |||
| } | |||
| val warehouseIds = warehouses.mapNotNull { it.id } | |||
| println("Found ${warehouses.size} warehouses for ALL sections") | |||
| // 2. 拿所有这些仓库下面的 lot line | |||
| val inventoryLotLines = inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalse(warehouseIds) | |||
| println("Found ${inventoryLotLines.size} inventory lot lines for ALL sections") | |||
| // 3. 如果传了 stockTakeId,就把「同一轮」的所有 stockTake 找出来(stockTakeRoundId,舊資料則 planStart) | |||
| val roundStockTakeIds: Set<Long> = if (stockTakeId != null) { | |||
| @@ -254,7 +241,6 @@ class StockTakeRecordService( | |||
| // 4. 如果有 stockTakeId,则预先把这一轮相关的 stockTakeRecord 查出来建 map(避免全表扫描 + N^2) | |||
| val roundStockTakeRecords = if (stockTakeId != null && roundStockTakeIds.isNotEmpty()) { | |||
| stockTakeRecordRepository.findAllByStockTakeIdInAndDeletedIsFalse(roundStockTakeIds) | |||
| .filter { it.warehouse?.id in warehouseIds } | |||
| } else { | |||
| emptyList() | |||
| } | |||
| @@ -262,7 +248,42 @@ class StockTakeRecordService( | |||
| .groupBy { Pair(it.lotId ?: 0L, it.warehouse?.id ?: 0L) } | |||
| .mapValues { (_, records) -> records.maxByOrNull { it.id ?: 0L }!! } | |||
| // 4.1 预先拉取本轮 stockTakeLine,避免每行调用 repository 形成 N+1 | |||
| // A:Approver 只处理“已有 picker record 的行” | |||
| // stockTakeId != null 时,allowedPairs 里一定对应 stockTakeRecordId != null | |||
| val allowedPairs: Set<Pair<Long, Long>> = if (stockTakeId != null) { | |||
| stockTakeRecordsMap.keys | |||
| } else { | |||
| emptySet() | |||
| } | |||
| // 4.1 只抓本轮 record 涉及到的 lot/warehouse 对应库存行(避免扫描全仓库 inventory_lot_line) | |||
| val inventoryLotLines = if (stockTakeId != null) { | |||
| val lotIds = roundStockTakeRecords.mapNotNull { it.lotId }.toSet() | |||
| val warehouseIds = roundStockTakeRecords.mapNotNull { it.warehouse?.id }.toSet() | |||
| if (lotIds.isEmpty() || warehouseIds.isEmpty()) { | |||
| emptyList() | |||
| } else { | |||
| inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse( | |||
| warehouseIds = warehouseIds, | |||
| inventoryLotIds = lotIds | |||
| ).filter { ill -> | |||
| val lotId = ill.inventoryLot?.id | |||
| val warehouseId = ill.warehouse?.id | |||
| lotId != null && warehouseId != null && allowedPairs.contains(Pair(lotId, warehouseId)) | |||
| } | |||
| } | |||
| } else { | |||
| val warehouses = warehouseRepository.findAllByDeletedIsFalse() | |||
| if (warehouses.isEmpty()) { | |||
| logger.warn("No warehouses found for approverInventoryLotDetailsAll") | |||
| return RecordsRes(emptyList(), 0) | |||
| } | |||
| val warehouseIds = warehouses.mapNotNull { it.id } | |||
| println("Found ${warehouses.size} warehouses for ALL sections") | |||
| inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalse(warehouseIds) | |||
| } | |||
| // 4.2 预先拉取本轮 stockTakeLine,避免每行调用 repository 形成 N+1 | |||
| val stockTakeLineMap = if (stockTakeId != null && roundStockTakeRecords.isNotEmpty()) { | |||
| val lineIds = inventoryLotLines.mapNotNull { it.id }.toSet() | |||
| val roundIds = roundStockTakeIds | |||
| @@ -277,8 +298,10 @@ class StockTakeRecordService( | |||
| emptyMap() | |||
| } | |||
| val targetInventoryLotLines = inventoryLotLines | |||
| // 5. 组装 InventoryLotDetailResponse(基本复制 section 版里的 map 那段) | |||
| val allResults = inventoryLotLines.map { ill -> | |||
| val allResults = targetInventoryLotLines.map { ill -> | |||
| val inventoryLot = ill.inventoryLot | |||
| val item = inventoryLot?.item | |||
| val warehouse = ill.warehouse | |||
| @@ -336,32 +359,42 @@ class StockTakeRecordService( | |||
| } | |||
| // 6. 过滤结果: | |||
| // 如果带了 stockTakeId,表示只看这一轮盘点的记录,此时只保留「已有 stockTakeRecord 的行」(即 picker 已经盘点过的行) | |||
| // 如果没有带 stockTakeId,则沿用原逻辑:availableQty > 0 或已有盘点记录 | |||
| // stockTakeId != null 时已在 map 前过滤 allowedPairs => 只保留 stockTakeRecordId != null 的行 | |||
| val filteredResults = if (stockTakeId != null) { | |||
| allResults.filter { response -> | |||
| val av = response.availableQty ?: BigDecimal.ZERO | |||
| av.compareTo(BigDecimal.ZERO) > 0 || response.stockTakeRecordId != null | |||
| } | |||
| allResults | |||
| } else { | |||
| allResults.filter { response -> | |||
| val av = response.availableQty ?: BigDecimal.ZERO | |||
| av.compareTo(BigDecimal.ZERO) > 0 || response.stockTakeRecordId != null | |||
| } | |||
| } | |||
| val approvalFilteredResults = when (approvalView?.lowercase()) { | |||
| "approved" -> filteredResults.filter { response -> | |||
| response.stockTakeRecordStatus == "completed" || | |||
| response.approverQty != null || | |||
| response.finalQty != null | |||
| } | |||
| "pending" -> filteredResults.filter { response -> | |||
| !(response.stockTakeRecordStatus == "completed" || | |||
| response.approverQty != null || | |||
| response.finalQty != null) | |||
| } | |||
| else -> filteredResults | |||
| } | |||
| // 7. 分页(和 section 版一模一样) | |||
| val pageable = PageRequest.of(pageNum, pageSize) | |||
| val startIndex = pageable.offset.toInt() | |||
| val endIndex = minOf(startIndex + pageSize, filteredResults.size) | |||
| val endIndex = minOf(startIndex + pageSize, approvalFilteredResults.size) | |||
| val paginatedResult = if (startIndex < filteredResults.size) { | |||
| filteredResults.subList(startIndex, endIndex) | |||
| val paginatedResult = if (startIndex < approvalFilteredResults.size) { | |||
| approvalFilteredResults.subList(startIndex, endIndex) | |||
| } else { | |||
| emptyList() | |||
| } | |||
| return RecordsRes(paginatedResult, filteredResults.size) | |||
| return RecordsRes(paginatedResult, approvalFilteredResults.size) | |||
| } | |||
| open fun AllApproverStockTakeList(): List<AllPickedStockTakeListReponse> { | |||
| // Overall 卡:只取“最新一轮”,并且总数口径与 | |||
| @@ -64,6 +64,32 @@ class StockTakeRecordController( | |||
| pageSize = pageSize | |||
| ) | |||
| } | |||
| @GetMapping("/approverInventoryLotDetailsAllPending") | |||
| fun getApproverInventoryLotDetailsAllPending( | |||
| @RequestParam(required = false) stockTakeId: Long?, | |||
| @RequestParam(required = false, defaultValue = "0") pageNum: Int, | |||
| @RequestParam(required = false, defaultValue = "2147483647") pageSize: Int | |||
| ): RecordsRes<InventoryLotDetailResponse> { | |||
| return stockOutRecordService.getApproverInventoryLotDetailsAll( | |||
| stockTakeId = stockTakeId, | |||
| pageNum = pageNum, | |||
| pageSize = pageSize, | |||
| approvalView = "pending" | |||
| ) | |||
| } | |||
| @GetMapping("/approverInventoryLotDetailsAllApproved") | |||
| fun getApproverInventoryLotDetailsAllApproved( | |||
| @RequestParam(required = false) stockTakeId: Long?, | |||
| @RequestParam(required = false, defaultValue = "0") pageNum: Int, | |||
| @RequestParam(required = false, defaultValue = "50") pageSize: Int | |||
| ): RecordsRes<InventoryLotDetailResponse> { | |||
| return stockOutRecordService.getApproverInventoryLotDetailsAll( | |||
| stockTakeId = stockTakeId, | |||
| pageNum = pageNum, | |||
| pageSize = pageSize, | |||
| approvalView = "approved" | |||
| ) | |||
| } | |||
| @GetMapping("/AllApproverStockTakeList") | |||
| fun AllApproverStockTakeList(): List<AllPickedStockTakeListReponse> { | |||
| return stockOutRecordService.AllApproverStockTakeList() | |||
| @@ -0,0 +1,8 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset Enson:alter_do_pick_order_record_box_number | |||
| ALTER TABLE `fpsmsdb`.`do_pick_order_record` | |||
| CHANGE COLUMN `BoxNumber` `cartonQty` INT NULL AFTER `deleted`; | |||