| @@ -33,6 +33,7 @@ import java.util.UUID | |||||
| import java.util.Comparator | import java.util.Comparator | ||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | ||||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | ||||
| import org.springframework.transaction.annotation.Transactional | |||||
| @Service | @Service | ||||
| open class BomService( | open class BomService( | ||||
| @@ -158,6 +159,265 @@ open class BomService( | |||||
| return response | 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 ------------------------------- ///////// | //////// -------------------------------- for excel import ------------------------------- ///////// | ||||
| private fun saveBomEntity(req: ImportBomRequest): Bom { | private fun saveBomEntity(req: ImportBomRequest): Bom { | ||||
| val item = itemsRepository.findByCodeAndDeletedFalse(req.code) ?: itemsRepository.findByNameAndDeletedFalse(req.name) | val item = itemsRepository.findByCodeAndDeletedFalse(req.code) ?: itemsRepository.findByNameAndDeletedFalse(req.name) | ||||
| @@ -2544,6 +2804,7 @@ println("=====================================") | |||||
| BomMaterialDto( | BomMaterialDto( | ||||
| itemCode = m.item?.code, | itemCode = m.item?.code, | ||||
| itemName = m.item?.name ?: m.itemName, | itemName = m.item?.name ?: m.itemName, | ||||
| isConsumable = m.isConsumable, | |||||
| baseQty = m.baseQty, | baseQty = m.baseQty, | ||||
| baseUom = m.baseUnitName, | baseUom = m.baseUnitName, | ||||
| stockQty = m.stockQty, | stockQty = m.stockQty, | ||||
| @@ -2558,8 +2819,10 @@ println("=====================================") | |||||
| .map { p -> | .map { p -> | ||||
| BomProcessDto( | BomProcessDto( | ||||
| seqNo = p.seqNo, | seqNo = p.seqNo, | ||||
| processCode = p.process?.code, | |||||
| processName = p.process?.name, | processName = p.process?.name, | ||||
| processDescription = p.description, | processDescription = p.description, | ||||
| equipmentCode = p.equipment?.code, | |||||
| equipmentName = p.equipment?.name, | equipmentName = p.equipment?.name, | ||||
| durationInMinute = p.durationInMinute, | durationInMinute = p.durationInMinute, | ||||
| prepTimeInMinute = p.prepTimeInMinute, | 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.GetMapping | ||||
| import org.springframework.web.bind.annotation.PathVariable | import org.springframework.web.bind.annotation.PathVariable | ||||
| import org.springframework.web.bind.annotation.PostMapping | 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.RequestBody | ||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RequestParam | 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.BomFormatCheckRequest | ||||
| import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload | 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.BomDetailResponse | ||||
| import com.ffii.fpsms.modules.master.web.models.EditBomRequest | |||||
| import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | ||||
| import java.util.logging.Logger | import java.util.logging.Logger | ||||
| import java.nio.file.Files | import java.nio.file.Files | ||||
| @@ -122,4 +124,16 @@ fun downloadBomFormatIssueLog( | |||||
| fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | ||||
| return bomService.getBomDetail(id) | 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( | data class BomMaterialDto( | ||||
| val itemCode: String?, | val itemCode: String?, | ||||
| val itemName: String?, | val itemName: String?, | ||||
| val isConsumable: Boolean?, | |||||
| val baseQty: BigDecimal?, | val baseQty: BigDecimal?, | ||||
| val baseUom: String?, | val baseUom: String?, | ||||
| val stockQty: BigDecimal?, | val stockQty: BigDecimal?, | ||||
| @@ -84,9 +85,12 @@ data class BomMaterialDto( | |||||
| data class BomProcessDto( | data class BomProcessDto( | ||||
| val seqNo: Long?, | val seqNo: Long?, | ||||
| val processCode: String?, | |||||
| val processName: String?, | val processName: String?, | ||||
| val processDescription: 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 durationInMinute: Int?, | ||||
| val prepTimeInMinute: Int?, | val prepTimeInMinute: Int?, | ||||
| val postProdTimeInMinute: 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") | @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> | 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(""" | @Query(""" | ||||
| SELECT ill FROM InventoryLotLine ill | SELECT ill FROM InventoryLotLine ill | ||||
| WHERE ill.warehouse.code = :warehouseCode | WHERE ill.warehouse.code = :warehouseCode | ||||
| @@ -223,24 +223,11 @@ class StockTakeRecordService( | |||||
| open fun getApproverInventoryLotDetailsAll( | open fun getApproverInventoryLotDetailsAll( | ||||
| stockTakeId: Long? = null, | stockTakeId: Long? = null, | ||||
| pageNum: Int = 0, | pageNum: Int = 0, | ||||
| pageSize: Int = 100 | |||||
| pageSize: Int = 100, | |||||
| approvalView: String? = null | |||||
| ): RecordsRes<InventoryLotDetailResponse> { | ): RecordsRes<InventoryLotDetailResponse> { | ||||
| println("getApproverInventoryLotDetailsAll called with stockTakeId: $stockTakeId, pageNum: $pageNum, pageSize: $pageSize") | 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) | // 3. 如果传了 stockTakeId,就把「同一轮」的所有 stockTake 找出来(stockTakeRoundId,舊資料則 planStart) | ||||
| val roundStockTakeIds: Set<Long> = if (stockTakeId != null) { | val roundStockTakeIds: Set<Long> = if (stockTakeId != null) { | ||||
| @@ -254,7 +241,6 @@ class StockTakeRecordService( | |||||
| // 4. 如果有 stockTakeId,则预先把这一轮相关的 stockTakeRecord 查出来建 map(避免全表扫描 + N^2) | // 4. 如果有 stockTakeId,则预先把这一轮相关的 stockTakeRecord 查出来建 map(避免全表扫描 + N^2) | ||||
| val roundStockTakeRecords = if (stockTakeId != null && roundStockTakeIds.isNotEmpty()) { | val roundStockTakeRecords = if (stockTakeId != null && roundStockTakeIds.isNotEmpty()) { | ||||
| stockTakeRecordRepository.findAllByStockTakeIdInAndDeletedIsFalse(roundStockTakeIds) | stockTakeRecordRepository.findAllByStockTakeIdInAndDeletedIsFalse(roundStockTakeIds) | ||||
| .filter { it.warehouse?.id in warehouseIds } | |||||
| } else { | } else { | ||||
| emptyList() | emptyList() | ||||
| } | } | ||||
| @@ -262,7 +248,42 @@ class StockTakeRecordService( | |||||
| .groupBy { Pair(it.lotId ?: 0L, it.warehouse?.id ?: 0L) } | .groupBy { Pair(it.lotId ?: 0L, it.warehouse?.id ?: 0L) } | ||||
| .mapValues { (_, records) -> records.maxByOrNull { it.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 stockTakeLineMap = if (stockTakeId != null && roundStockTakeRecords.isNotEmpty()) { | ||||
| val lineIds = inventoryLotLines.mapNotNull { it.id }.toSet() | val lineIds = inventoryLotLines.mapNotNull { it.id }.toSet() | ||||
| val roundIds = roundStockTakeIds | val roundIds = roundStockTakeIds | ||||
| @@ -277,8 +298,10 @@ class StockTakeRecordService( | |||||
| emptyMap() | emptyMap() | ||||
| } | } | ||||
| val targetInventoryLotLines = inventoryLotLines | |||||
| // 5. 组装 InventoryLotDetailResponse(基本复制 section 版里的 map 那段) | // 5. 组装 InventoryLotDetailResponse(基本复制 section 版里的 map 那段) | ||||
| val allResults = inventoryLotLines.map { ill -> | |||||
| val allResults = targetInventoryLotLines.map { ill -> | |||||
| val inventoryLot = ill.inventoryLot | val inventoryLot = ill.inventoryLot | ||||
| val item = inventoryLot?.item | val item = inventoryLot?.item | ||||
| val warehouse = ill.warehouse | val warehouse = ill.warehouse | ||||
| @@ -336,32 +359,42 @@ class StockTakeRecordService( | |||||
| } | } | ||||
| // 6. 过滤结果: | // 6. 过滤结果: | ||||
| // 如果带了 stockTakeId,表示只看这一轮盘点的记录,此时只保留「已有 stockTakeRecord 的行」(即 picker 已经盘点过的行) | |||||
| // 如果没有带 stockTakeId,则沿用原逻辑:availableQty > 0 或已有盘点记录 | |||||
| // stockTakeId != null 时已在 map 前过滤 allowedPairs => 只保留 stockTakeRecordId != null 的行 | |||||
| val filteredResults = if (stockTakeId != 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 { | } else { | ||||
| allResults.filter { response -> | allResults.filter { response -> | ||||
| val av = response.availableQty ?: BigDecimal.ZERO | val av = response.availableQty ?: BigDecimal.ZERO | ||||
| av.compareTo(BigDecimal.ZERO) > 0 || response.stockTakeRecordId != null | 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 版一模一样) | // 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, 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 { | } else { | ||||
| emptyList() | emptyList() | ||||
| } | } | ||||
| return RecordsRes(paginatedResult, filteredResults.size) | |||||
| return RecordsRes(paginatedResult, approvalFilteredResults.size) | |||||
| } | } | ||||
| open fun AllApproverStockTakeList(): List<AllPickedStockTakeListReponse> { | open fun AllApproverStockTakeList(): List<AllPickedStockTakeListReponse> { | ||||
| // Overall 卡:只取“最新一轮”,并且总数口径与 | // Overall 卡:只取“最新一轮”,并且总数口径与 | ||||
| @@ -64,6 +64,32 @@ class StockTakeRecordController( | |||||
| pageSize = pageSize | 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") | @GetMapping("/AllApproverStockTakeList") | ||||
| fun AllApproverStockTakeList(): List<AllPickedStockTakeListReponse> { | fun AllApproverStockTakeList(): List<AllPickedStockTakeListReponse> { | ||||
| return stockOutRecordService.AllApproverStockTakeList() | 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`; | |||||