From 785b700a0187332a29cbb267bdcf5a5e00da5b42 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Sat, 25 Apr 2026 16:22:36 +0800 Subject: [PATCH 01/12] doworbench,joworkbech,nonefinish consumable worbnech --- .../entity/DeliveryOrderPickOrder.kt | 77 + .../DeliveryOrderPickOrderRepository.kt | 24 + .../service/DeliveryOrderService.kt | 6 +- .../DoWorkbenchDopoAssignmentService.kt | 404 +++ .../service/DoWorkbenchMainService.kt | 2598 +++++++++++++++++ .../service/DoWorkbenchReleaseService.kt | 479 +++ .../WorkbenchTruckRoutingSummaryService.kt | 199 ++ .../web/DoWorkbenchController.kt | 272 ++ .../models/WorkbenchBatchScanPickRequest.kt | 11 + .../web/models/WorkbenchScanPickRequest.kt | 30 + .../WorkbenchTicketReleaseTableResponse.kt | 24 + .../jobOrder/entity/jobOrderMatching.kt | 0 .../service/JoWorkbenchMainService.kt | 556 ++++ .../service/JoWorkbenchReleaseService.kt | 21 + .../jobOrder/service/JobOrderService.kt | 22 +- .../jobOrder/web/JobOrderController.kt | 20 +- .../web/model/CreateJobOrderRequest.kt | 5 +- .../web/model/JobOrderActionRequest.kt | 6 + .../pickOrder/entity/PickOrderRepository.kt | 2 + .../service/HierarchicalFgPayloadAssembler.kt | 360 +++ .../pickOrder/service/PickOrderService.kt | 2 + .../service/PickOrderWorkbenchService.kt | 404 +++ .../pickOrder/web/PickOrderController.kt | 77 +- .../web/models/SearchPickOrderRequest.kt | 2 + .../entity/InventoryLotLineRepository.kt | 27 +- .../stock/entity/StockOutLIneRepository.kt | 12 + .../stock/service/InventoryLotLineService.kt | 183 +- .../service/StockOutLineWorkbenchService.kt | 702 +++++ .../SuggestedPickLotWorkbenchService.kt | 636 ++++ .../WorkbenchStockOutLinePickProgress.kt | 31 + .../stock/web/InventoryLotLineController.kt | 13 + .../modules/stock/web/model/LotLineInfo.kt | 9 +- .../20260408_01_Enson/01_alter_stock_take.sql | 68 + .../20260417_01_Enson/01_alter_stock_take.sql | 9 + .../20260420_01_Enson/03_alter_stock_take.sql | 5 + 35 files changed, 7204 insertions(+), 92 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrderRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchTruckRoutingSummaryService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchScanPickRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/entity/jobOrderMatching.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchReleaseService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/WorkbenchStockOutLinePickProgress.kt create mode 100644 src/main/resources/db/changelog/changes/20260408_01_Enson/01_alter_stock_take.sql create mode 100644 src/main/resources/db/changelog/changes/20260417_01_Enson/01_alter_stock_take.sql create mode 100644 src/main/resources/db/changelog/changes/20260420_01_Enson/03_alter_stock_take.sql diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt new file mode 100644 index 0000000..93e5171 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt @@ -0,0 +1,77 @@ +package com.ffii.fpsms.modules.deliveryOrder.entity + +import com.ffii.fpsms.modules.deliveryOrder.enums.DoPickOrderStatus +import jakarta.persistence.* +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.UpdateTimestamp +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +@Entity +@Table(name = "delivery_order_pick_order") +class DeliveryOrderPickOrder { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + @Column(name = "ticketNo", length = 50) + var ticketNo: String? = null + + @Column(name = "storeId", length = 10) + var storeId: String? = null + + @Column(name = "shopCode", length = 50) + var shopCode: String? = null + + @Column(name = "shopName", length = 255) + var shopName: String? = null + + @Column(name = "truckLanceCode", length = 50) + var truckLanceCode: String? = null + + @Column(name = "truckDepartureTime") + var truckDepartureTime: LocalTime? = null + + @Column(name = "requiredDeliveryDate") + var requiredDeliveryDate: LocalDate? = null + + @Column(name = "ticketReleaseTime") + var ticketReleaseTime: LocalDateTime? = null + + @Enumerated(EnumType.STRING) + @Column(name = "ticketStatus") + var ticketStatus: DoPickOrderStatus? = null + + @Column(name = "releaseType", length = 100) + var releaseType: String? = null + + @Column(name = "handledBy") + var handledBy: Long? = null + + @Column(name = "handlerName", length = 100) + var handlerName: String? = null + + @Column(name = "ticketCompleteDateTime") + var ticketCompleteDateTime: LocalDateTime? = null + + @Column(name = "deliveryNoteCode", length = 50) + var deliveryNoteCode: String? = null + + @Column(name = "cartonQty") + var cartonQty: Int? = null + + @CreationTimestamp + @Column(name = "created") + var created: LocalDateTime? = null + + @UpdateTimestamp + @Column(name = "modified") + var modified: LocalDateTime? = null + + @Column(name = "deleted") + var deleted: Boolean = false + + @Version + var version: Int = 0 +} \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrderRepository.kt new file mode 100644 index 0000000..0d957a5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrderRepository.kt @@ -0,0 +1,24 @@ +package com.ffii.fpsms.modules.deliveryOrder.entity + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface DeliveryOrderPickOrderRepository : JpaRepository { + @Query( + value = """ + SELECT dop.deliveryNoteCode + FROM delivery_order_pick_order dop + WHERE dop.deleted = 0 + AND dop.deliveryNoteCode LIKE CONCAT(:prefix, '%') + ORDER BY dop.deliveryNoteCode DESC + LIMIT 1 + """, + nativeQuery = true, + ) + fun findLatestDeliveryNoteCodeByPrefix(@Param("prefix") prefix: String): String? + + +} \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index f900240..00cfdfa 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -1393,7 +1393,7 @@ open class DeliveryOrderService( ) } - private fun routeFromStockOutsForItem( + fun routeFromStockOutsForItem( itemId: Long, pickOrderLineIdsByItemId: Map>, stockOutLinesByPickOrderLineId: Map>, @@ -1480,13 +1480,13 @@ open class DeliveryOrderService( ) } - private data class ParsedShopLabelForCartonLabel( + data class ParsedShopLabelForCartonLabel( val shopCode: String, val shopCodeAbbr: String, val shopNameForLabel: String ) - private fun parseShopLabelForCartonLabel(rawInput: String): ParsedShopLabelForCartonLabel { + fun parseShopLabelForCartonLabel(rawInput: String): ParsedShopLabelForCartonLabel { // Fixed input format: shopCode - shopName1-shopName2 val raw = rawInput.trim() diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt new file mode 100644 index 0000000..6e795c4 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt @@ -0,0 +1,404 @@ +package com.ffii.fpsms.modules.deliveryOrder.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.deliveryOrder.web.models.AssignByDeliveryOrderPickOrderIdRequest +import com.ffii.fpsms.modules.deliveryOrder.web.models.AssignByLaneRequest +import com.ffii.fpsms.modules.master.web.models.MessageResponse +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus +import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService +import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService +import com.ffii.fpsms.modules.user.entity.User +import com.ffii.fpsms.modules.user.entity.UserRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.sql.Time +import java.time.LocalDateTime +import java.time.LocalTime + +@Service +open class DoWorkbenchDopoAssignmentService( + private val jdbcDao: JdbcDao, + private val userRepository: UserRepository, + private val pickOrderRepository: PickOrderRepository, + private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, + private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, +) { + + @Transactional + open fun assignByDeliveryOrderPickOrderId(request: AssignByDeliveryOrderPickOrderIdRequest): MessageResponse { + val user = userRepository.findById(request.userId).orElse(null) + ?: return userNotFound() + + userBusyResponse(request.userId)?.let { return it } + + val headerRows = jdbcDao.queryForList( + """ + SELECT id, ticketStatus + FROM fpsmsdb.delivery_order_pick_order + WHERE id = :id AND deleted = 0 + """.trimIndent(), + mapOf("id" to request.deliveryOrderPickOrderId) + ) + if (headerRows.isEmpty()) { + return MessageResponse( + id = null, code = "NOT_FOUND", name = null, type = null, + message = "delivery_order_pick_order not found", errorPosition = null, entity = null + ) + } + val headerRow = headerRows.first() + val stKey = headerRow.keys.find { it.equals("ticketStatus", true) } + val status = stKey?.let { headerRow[it]?.toString() } ?: "" + if (status !in listOf("pending", "released")) { + return MessageResponse( + id = null, code = "INVALID_STATUS", name = null, type = null, + message = "Ticket not assignable", errorPosition = null, entity = null + ) + } + + return applyDopoAssignment(user, request.userId, request.deliveryOrderPickOrderId, mode = AssignMode.ATOMIC) + } + + /** Legacy assign (V1): old FG-style (no atomic conflict guard). */ + @Transactional + open fun assignByDeliveryOrderPickOrderIdV1(request: AssignByDeliveryOrderPickOrderIdRequest): MessageResponse { + val user = userRepository.findById(request.userId).orElse(null) + ?: return userNotFound() + + userBusyResponse(request.userId)?.let { return it } + + val headerRows = jdbcDao.queryForList( + """ + SELECT id, ticketStatus + FROM fpsmsdb.delivery_order_pick_order + WHERE id = :id AND deleted = 0 + """.trimIndent(), + mapOf("id" to request.deliveryOrderPickOrderId) + ) + if (headerRows.isEmpty()) { + return MessageResponse( + id = null, code = "NOT_FOUND", name = null, type = null, + message = "delivery_order_pick_order not found", errorPosition = null, entity = null + ) + } + val headerRow = headerRows.first() + val stKey = headerRow.keys.find { it.equals("ticketStatus", true) } + val status = stKey?.let { headerRow[it]?.toString() } ?: "" + if (status !in listOf("pending", "released")) { + return MessageResponse( + id = null, code = "INVALID_STATUS", name = null, type = null, + message = "Ticket not assignable", errorPosition = null, entity = null + ) + } + + return applyDopoAssignment(user, request.userId, request.deliveryOrderPickOrderId, mode = AssignMode.LEGACY_V1) + } + + /** + * Same UX as [DoPickOrderAssignmentService.assignByLane] but candidates come from + * [delivery_order_pick_order] (+ unassigned [pick_order]), not [do_pick_order]. + */ + @Transactional + open fun assignByLaneForWorkbench(request: AssignByLaneRequest): MessageResponse { + val user = userRepository.findById(request.userId).orElse(null) + ?: return userNotFound() + + userBusyResponse(request.userId)?.let { return it } + + val actualStoreId = when (request.storeId) { + "2/F" -> "2/F" + "4/F" -> "4/F" + else -> request.storeId + } + println(" DEBUG: assignByLaneForWorkbench storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}") + + val params = mutableMapOf( + "storeId" to actualStoreId, + "lance" to request.truckLanceCode.trim(), + ) + val sql = StringBuilder( + """ + SELECT dop.id + FROM fpsmsdb.delivery_order_pick_order dop + WHERE dop.deleted = 0 + AND dop.storeId = :storeId + AND dop.truckLanceCode = :lance + AND dop.ticketStatus IN ('pending', 'released') + AND dop.handledBy IS NULL + AND EXISTS ( + SELECT 1 FROM fpsmsdb.pick_order po + WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 AND po.assignTo IS NULL + ) + """.trimIndent() + ) + if (request.requiredDate != null) { + sql.append(" AND dop.requiredDeliveryDate = :reqDate ") + params["reqDate"] = request.requiredDate + } + val depSqlTime = parseDepartureTimeToSql(request.truckDepartureTime) + if (depSqlTime != null) { + sql.append(" AND dop.truckDepartureTime = :depTime ") + params["depTime"] = depSqlTime + } + // Fetch a batch of candidates and try atomic-assign sequentially. + // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. + val candidateLimit = 50 + val maxRounds = 3 + + sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT $candidateLimit ") + + fun extractIds(rows: List>): List { + if (rows.isEmpty()) return emptyList() + return rows.mapNotNull { r -> + val idKey = r.keys.find { it.equals("id", true) } ?: return@mapNotNull null + (r[idKey] as? Number)?.toLong() + } + } + + var round = 0 + while (round < maxRounds) { + round++ + val candidates = try { + jdbcDao.queryForList(sql.toString(), params) + } catch (e: Exception) { + println("❌ assignByLaneForWorkbench query: ${e.message}") + emptyList() + } + val ids = extractIds(candidates) + println(" DEBUG: assignByLaneForWorkbench round=$round candidates=${ids.size}") + if (ids.isEmpty()) break + + for (dopoId in ids) { + val res = applyDopoAssignment(user, request.userId, dopoId, mode = AssignMode.ATOMIC) + val code = res.code?.trim()?.uppercase() + when (code) { + "SUCCESS" -> return res + "CONFLICT" -> { + // Someone else took this ticket; try the next candidate. + continue + } + else -> { + // Unexpected error (e.g. UPDATE_FAILED in other modes). Return as-is. + return res + } + } + } + // All candidates were concurrently assigned; loop to re-query a fresh batch. + } + + return MessageResponse( + id = null, code = "NO_ORDERS", name = null, type = null, + message = "No available workbench ticket for this lane.", errorPosition = null, entity = null + ) + } + + /** Legacy lane assign (V1): old FG-style (no atomic conflict guard). */ + @Transactional + open fun assignByLaneForWorkbenchV1(request: AssignByLaneRequest): MessageResponse { + val user = userRepository.findById(request.userId).orElse(null) + ?: return userNotFound() + + userBusyResponse(request.userId)?.let { return it } + + val actualStoreId = when (request.storeId) { + "2/F" -> "2/F" + "4/F" -> "4/F" + else -> request.storeId + } + println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}") + + val params = mutableMapOf( + "storeId" to actualStoreId, + "lance" to request.truckLanceCode.trim(), + ) + val sql = StringBuilder( + """ + SELECT dop.id + FROM fpsmsdb.delivery_order_pick_order dop + WHERE dop.deleted = 0 + AND dop.storeId = :storeId + AND dop.truckLanceCode = :lance + AND dop.ticketStatus IN ('pending', 'released') + AND EXISTS ( + SELECT 1 FROM fpsmsdb.pick_order po + WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 AND po.assignTo IS NULL + ) + """.trimIndent() + ) + if (request.requiredDate != null) { + sql.append(" AND dop.requiredDeliveryDate = :reqDate ") + params["reqDate"] = request.requiredDate + } + val depSqlTime = parseDepartureTimeToSql(request.truckDepartureTime) + if (depSqlTime != null) { + sql.append(" AND dop.truckDepartureTime = :depTime ") + params["depTime"] = depSqlTime + } + sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT 1 ") + + val candidates = try { + jdbcDao.queryForList(sql.toString(), params) + } catch (e: Exception) { + println("❌ assignByLaneForWorkbenchV1 query: ${e.message}") + emptyList() + } + println(" DEBUG: assignByLaneForWorkbenchV1 found ${candidates.size} candidate delivery_order_pick_order row(s)") + if (candidates.isEmpty()) { + return MessageResponse( + id = null, code = "NO_ORDERS", name = null, type = null, + message = "No available workbench ticket for this lane.", errorPosition = null, entity = null + ) + } + val idKey = candidates.first().keys.find { it.equals("id", true) } ?: return MessageResponse( + id = null, code = "NO_ORDERS", name = null, type = null, + message = "No candidate id", errorPosition = null, entity = null + ) + val dopoId = (candidates.first()[idKey] as? Number)?.toLong() + ?: return MessageResponse( + id = null, code = "NO_ORDERS", name = null, type = null, + message = "Invalid candidate id", errorPosition = null, entity = null + ) + + return applyDopoAssignment(user, request.userId, dopoId, mode = AssignMode.LEGACY_V1) + } + + private fun userNotFound() = MessageResponse( + id = null, code = "USER_NOT_FOUND", name = null, type = null, + message = "User not found", errorPosition = null, entity = null + ) + + private fun userBusyResponse(userId: Long): MessageResponse? { + val user = userRepository.findById(userId).orElse(null) ?: return null + val busy = pickOrderRepository.findAllByAssignToIdAndStatusIn( + userId, + listOf(PickOrderStatus.PENDING, PickOrderStatus.RELEASED) + ).filter { it.deliveryOrder != null } + return if (busy.isNotEmpty()) { + MessageResponse( + id = null, code = "USER_BUSY", name = null, type = null, + message = "User already has pick order(s) in progress.", + errorPosition = null, + entity = mapOf("count" to busy.size) + ) + } else null + } + + private fun parseDepartureTimeToSql(raw: String?): Time? { + if (raw.isNullOrBlank()) return null + val s = raw.trim() + val lt = runCatching { LocalTime.parse(s) }.getOrNull() + ?: runCatching { LocalTime.parse("$s:00") }.getOrNull() + ?: return null + return Time.valueOf(lt) + } + + private enum class AssignMode { ATOMIC, LEGACY_V1 } + + private fun applyDopoAssignment(user: User, userId: Long, dopoId: Long, mode: AssignMode): MessageResponse { + val now = LocalDateTime.now() + val (sql, failCode, failMsg) = when (mode) { + AssignMode.ATOMIC -> Triple( + """ + UPDATE fpsmsdb.delivery_order_pick_order + SET handledBy = :handledBy, + handlerName = :handlerName, + ticketStatus = 'released', + ticketReleaseTime = :trt, + modified = :modified, + modifiedBy = :modifiedBy + WHERE id = :id AND deleted = 0 + AND ticketStatus IN ('pending','released') + AND handledBy IS NULL + """.trimIndent(), + "CONFLICT", + "Ticket already assigned by another user; please refresh and retry", + ) + AssignMode.LEGACY_V1 -> Triple( + """ + UPDATE fpsmsdb.delivery_order_pick_order + SET handledBy = :handledBy, + handlerName = :handlerName, + ticketStatus = 'released', + ticketReleaseTime = :trt, + modified = :modified, + modifiedBy = :modifiedBy + WHERE id = :id AND deleted = 0 + AND ticketStatus IN ('pending','released') + """.trimIndent(), + "UPDATE_FAILED", + "Could not update delivery_order_pick_order", + ) + } + + val updated = jdbcDao.executeUpdate( + sql, + mapOf( + "handledBy" to userId, + "handlerName" to (user.name ?: ""), + "trt" to now, + "modified" to now, + "modifiedBy" to "workbench", + "id" to dopoId, + ) + ) + if (updated <= 0) { + return MessageResponse( + id = null, code = failCode, name = null, type = null, + message = failMsg, errorPosition = null, entity = null + ) + } + + // 1) 取该 ticket 下所有 pick_order id(给后续 SPL/SOL 使用) + val poIds = pickOrderRepository.findIdsByDeliveryOrderPickOrderId(dopoId) + + // 2) 一次性 bulk assign + released(取代 N 次 findById/save) + if (poIds.isNotEmpty()) { + pickOrderRepository.bulkAssignWorkbenchTicket( + dopoId = dopoId, + userId = userId, + status = PickOrderStatus.RELEASED.value, // 若 enum 无 value,就传 "released" + modified = now, + modifiedBy = "workbench", + ) + } + + // 下方逻辑先保持不变(你后续再做第二阶段批次化) + val storeIdRow = jdbcDao.queryForList( + "SELECT storeId FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0", + mapOf("id" to dopoId), + ).firstOrNull() + val storeIdKey = storeIdRow?.keys?.find { it.equals("storeId", true) } + val storeId = storeIdKey?.let { storeIdRow[it]?.toString() }?.trim()?.takeIf { it.isNotEmpty() } + + for (poId in poIds) { + suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( + pickOrderId = poId, + storeId = storeId, + excludeWarehouseCodes = null, + ) + stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, userId) + } + + val ticketNoRow = jdbcDao.queryForList( + "SELECT ticketNo FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0", + mapOf("id" to dopoId) + ).firstOrNull() + val ticketNoKey = ticketNoRow?.keys?.find { it.equals("ticketNo", true) } + val ticketNo = ticketNoKey?.let { ticketNoRow[it]?.toString() } + + return MessageResponse( + id = dopoId, + code = "SUCCESS", + name = null, + type = null, + message = "Assigned workbench ticket from lane", + errorPosition = null, + entity = mapOf( + "deliveryOrderPickOrderId" to dopoId, + "ticketNo" to ticketNo, + "numberOfPickOrders" to poIds.size, + "pickOrderIds" to poIds + ) + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt new file mode 100644 index 0000000..7178b22 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -0,0 +1,2598 @@ +package com.ffii.fpsms.modules.deliveryOrder.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.bag.entity.BagRepository +import com.ffii.fpsms.modules.bag.service.BagService +import com.ffii.fpsms.modules.bag.web.model.CreateBagLotLineRequest +import com.ffii.fpsms.modules.common.CodeGenerator +import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecord +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecordRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecord +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderPickOrderRepository +import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus +import com.ffii.fpsms.modules.deliveryOrder.enums.DoPickOrderStatus +import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchScanPickRequest +import com.ffii.fpsms.modules.master.entity.ItemUomRespository +import com.ffii.fpsms.modules.master.service.ItemUomService +import com.ffii.fpsms.modules.master.web.models.MessageResponse +import com.ffii.fpsms.modules.pickOrder.web.models.CompletedDoPickOrderResponse +import com.ffii.fpsms.modules.pickOrder.web.models.FgPickOrderSummary +import com.ffii.fpsms.modules.pickOrder.web.models.GetCompletedDoPickOrdersRequest +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderLineStatus +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderType +import com.ffii.fpsms.modules.pickOrder.service.PickOrderService +import com.ffii.fpsms.modules.pickOrder.service.assembleHierarchicalFgPayload +import com.ffii.fpsms.modules.stock.entity.Inventory +import com.ffii.fpsms.modules.stock.entity.InventoryLotLine +import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository +import com.ffii.fpsms.modules.stock.entity.InventoryLotRepository +import com.ffii.fpsms.modules.stock.entity.StockOutLine +import com.ffii.fpsms.modules.stock.entity.StockLedger +import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository +import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository +import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository +import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus +import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo +import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService +import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService +import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress +import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import com.ffii.fpsms.modules.deliveryOrder.web.models.StoreLaneSummary +import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneRow +import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneBtn +import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleasedDoPickOrderListItem +import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchTicketReleaseTableResponse +import com.ffii.fpsms.modules.user.service.UserService +import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository +import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository +import kotlin.system.measureTimeMillis +import org.slf4j.LoggerFactory +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import org.springframework.orm.ObjectOptimisticLockingFailureException +import com.ffii.core.utils.PdfUtils +import com.ffii.core.utils.ZebraPrinterUtil +import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry +import com.ffii.fpsms.modules.master.service.PrinterService +import com.ffii.fpsms.modules.master.entity.ItemsRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderPickOrder +import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDeliveryNoteRequest +import com.ffii.fpsms.modules.deliveryOrder.web.models.ExportDeliveryNoteRequest +import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsRequest +import com.ffii.fpsms.modules.deliveryOrder.web.models.ExportDNLabelsRequest +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import org.springframework.core.io.ClassPathResource +import net.sf.jasperreports.engine.JasperCompileManager +import net.sf.jasperreports.engine.JasperExportManager +import net.sf.jasperreports.engine.JasperPrint +import java.io.File +import java.io.FileNotFoundException +import java.util.Locale +import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService +import org.springframework.jdbc.core.JdbcTemplate +/** + * DO workbench: pick execution and related operations (v1: [scanPick]). + */ +@Service +open class DoWorkbenchMainService( + private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, + private val stockOutLIneRepository: StockOutLIneRepository, + private val pickOrderLineRepository: PickOrderLineRepository, + private val pickOrderRepository: PickOrderRepository, + private val inventoryLotLineRepository: InventoryLotLineRepository, + private val inventoryLotRepository: InventoryLotRepository, + private val suggestPickLotRepository: SuggestPickLotRepository, + private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, + private val stockLedgerRepository: StockLedgerRepository, + private val itemUomService: ItemUomService, + private val itemUomRespository: ItemUomRespository, + private val bagRepository: BagRepository, + private val bagService: BagService, + private val pickOrderService: PickOrderService, + private val deliveryOrderService: DeliveryOrderService, + private val doPickOrderRepository: DoPickOrderRepository, + private val doPickOrderRecordRepository: DoPickOrderRecordRepository, + private val doPickOrderLineRepository: DoPickOrderLineRepository, + private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, + private val deliveryOrderPickOrderRepository: DeliveryOrderPickOrderRepository, + private val deliveryOrderRepository: DeliveryOrderRepository, + private val jdbcDao: JdbcDao, + private val truckRepository: TruckRepository, + private val userService: UserService, + private val jdbcTemplate: JdbcTemplate, + private val joPickOrderRepository: JoPickOrderRepository, + private val printerService: PrinterService, + private val itemsRepository: ItemsRepository, +) { + @PersistenceContext + private lateinit var entityManager: EntityManager + + companion object { + private val log = LoggerFactory.getLogger(DoWorkbenchMainService::class.java) + } + + private sealed interface WorkbenchScanIllResolve { + data class Found(val ill: InventoryLotLine) : WorkbenchScanIllResolve + data class NotFound(val message: String) : WorkbenchScanIllResolve + } + + /** + * Prefer QR [stockInLineId] → `inventory_lot` → `inventory_lot_line` so duplicate [lotNo] on the same item + * does not pick the wrong row (`OrderByIdDesc`). + */ + private fun resolveInventoryLotLineByIdForWorkbenchScan( + inventoryLotLineId: Long, + itemId: Long, + lotNoTrimmed: String, + ): WorkbenchScanIllResolve { + val ill = inventoryLotLineRepository.findById(inventoryLotLineId).orElse(null) + ?: return WorkbenchScanIllResolve.NotFound("Inventory lot line not found (id=$inventoryLotLineId)") + if (ill.deleted) { + return WorkbenchScanIllResolve.NotFound("Inventory lot line is deleted") + } + if (ill.inventoryLot?.item?.id != itemId) { + return WorkbenchScanIllResolve.NotFound("Scanned lot line item does not match pick line item") + } + val invLotNo = (ill.inventoryLot?.lotNo ?: "").trim() + if (lotNoTrimmed.isNotEmpty() && invLotNo.isNotEmpty() && invLotNo != lotNoTrimmed) { + return WorkbenchScanIllResolve.NotFound( + "Lot number mismatch: inventory has $invLotNo, request was $lotNoTrimmed", + ) + } + val expiry = ill.inventoryLot?.expiryDate + if (expiry != null && expiry.isBefore(LocalDate.now())) { + return WorkbenchScanIllResolve.NotFound("Lot is expired (expiry=$expiry)") + } + return WorkbenchScanIllResolve.Found(ill) + } + + private fun resolveWorkbenchScannedInventoryLotLine( + stockInLineId: Long?, + lotNoTrimmed: String, + itemId: Long, + sol: StockOutLine, + ): WorkbenchScanIllResolve { + if (stockInLineId != null && stockInLineId > 0) { + val invLot = inventoryLotRepository.findByStockInLineIdAndDeletedFalse(stockInLineId) + ?: return WorkbenchScanIllResolve.NotFound("No inventory lot for stockInLineId=$stockInLineId") + if (invLot.item?.id != itemId) { + return WorkbenchScanIllResolve.NotFound( + "Stock-in line lot item does not match pick line (stockInLineId=$stockInLineId)", + ) + } + val invLotNo = (invLot.lotNo ?: "").trim() + if (lotNoTrimmed.isNotEmpty() && invLotNo.isNotEmpty() && invLotNo != lotNoTrimmed) { + return WorkbenchScanIllResolve.NotFound( + "Lot number mismatch: stock-in has $invLotNo, request was $lotNoTrimmed", + ) + } + val lotPk = invLot.id + ?: return WorkbenchScanIllResolve.NotFound("Inventory lot id missing (stockInLineId=$stockInLineId)") + val lines = inventoryLotLineRepository.findAllByInventoryLotId(lotPk).filter { !it.deleted } + if (lines.isEmpty()) { + return WorkbenchScanIllResolve.NotFound("No inventory lot lines for inventoryLotId=$lotPk") + } + val solIll = sol.inventoryLotLine?.takeIf { sill -> lines.any { it.id == sill.id } } + val picked: InventoryLotLine = when { + solIll != null -> solIll + lines.size == 1 -> lines.first() + else -> { + val avail = lines.filter { it.status == InventoryLotLineStatus.AVAILABLE } + when { + avail.size == 1 -> avail.first() + avail.isNotEmpty() -> avail.minByOrNull { it.id ?: Long.MAX_VALUE }!! + else -> lines.minByOrNull { it.id ?: Long.MAX_VALUE }!! + } + } + } + return WorkbenchScanIllResolve.Found(picked) + } + val byLotNo = inventoryLotLineRepository + .findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc(lotNoTrimmed, itemId) + ?: return WorkbenchScanIllResolve.NotFound("Lot not found for this item: $lotNoTrimmed") + return WorkbenchScanIllResolve.Found(byLotNo) + } + + /** + * Job-order workbench: mirror [StockOutLine.handledBy] onto [jo_pick_order.handledBy] for the same pick line. + * Delivery-order pick orders ([PickOrderType.DELIVERY_ORDER] / `do`) are unchanged. + */ + private fun updateJoPickOrderHandledByIfJobOrder( + pickOrderId: Long?, + itemId: Long?, + userId: Long, + ) { + if (pickOrderId == null || itemId == null) return + val po = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return + if (po.type != PickOrderType.JOB_ORDER) return + val jpo = joPickOrderRepository.findByPickOrderIdAndItemId(pickOrderId, itemId).orElse(null) ?: return + jpo.handledBy = userId + joPickOrderRepository.save(jpo) + } + + /** + * Workbench scan-pick (DO FG): + * 1) Post outbound on scanned inventory lot line first; on failure return a clear message. + * 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty], + * treat as **split**: complete this SOL, rebuild suggestions **for this pick order line only**, ensure next SOL — + * set **pick_order_line.status** to `partially_completed` (only in this split case). + * 3) If the user passes [qty] less than remaining on this SOL (short submit), SOL and POL both go **completed** + * even when sum < POL.req (no `partially_completed` on POL). + * 4) If the user passes [qty] greater than this SOL’s remaining chunk, honour it up to **available** on the scanned + * lot line (`plannedDelta`); `rebuild` + `ensureStockOutLines` then reconcile extra SOLs / POL as needed. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse { + val wall0 = System.nanoTime() + var mark = wall0 + fun lapMs(): Long { + val n = System.nanoTime() + val d = (n - mark) / 1_000_000 + mark = n + return d + } + + val lotNo = request.lotNo.trim() + // Workbench "zero-complete": allow empty lotNo when explicit qty=0, completing SOL without inventory posting. + // This is used by batch submit to close noLot / expired / unavailable lines. + val explicitQty = request.qty?.coerceAtLeast(BigDecimal.ZERO) + if (explicitQty != null && explicitQty.compareTo(BigDecimal.ZERO) == 0) { + val sol = stockOutLIneRepository.findById(request.stockOutLineId).orElse(null) + ?: return MessageResponse( + id = request.stockOutLineId, name = "scan_pick", code = "NOT_FOUND", type = "workbench_scan_pick", + message = "Stock out line not found", errorPosition = null, entity = null, + ) + val st = sol.status?.trim()?.lowercase() ?: "" + if (st == "completed" || st == StockOutLineStatus.COMPLETE.status.lowercase()) { + // Idempotent: treat as success so batch operations can retry safely. + // Still refresh POL/PO/DOPO ticket — zero-complete previously did not run header completion. + refreshWorkbenchPickOrderProgressAfterSolChange(sol.id!!) + val mapped = stockOutLIneRepository.findStockOutLineInfoById(sol.id!!) + return MessageResponse( + id = sol.id, + name = lotNo, + code = "SUCCESS", + type = "workbench_scan_pick", + message = "Stock out line already completed", + errorPosition = null, + entity = mapped, + ) + } + if (st == "rejected" || st == StockOutLineStatus.REJECTED.status.lowercase()) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Stock out line is rejected", errorPosition = null, entity = null, + ) + } + sol.status = StockOutLineStatus.COMPLETE.status + sol.pickTime = LocalDateTime.now() + sol.handledBy = request.userId + if (sol.startTime == null) sol.startTime = LocalDateTime.now() + sol.endTime = LocalDateTime.now() + stockOutLIneRepository.saveAndFlush(sol) + val polZero = sol.pickOrderLine + updateJoPickOrderHandledByIfJobOrder( + pickOrderId = polZero?.pickOrder?.id, + itemId = polZero?.item?.id, + userId = request.userId, + ) + // Do NOT call ensureStockOutLinesForPickOrderLineNoHold here: it creates new PENDING SOL rows from + // suggestions and prevents POL completion (tryCompletePickOrderLineWorkbench / checkWorkbenchPickOrderLineCompleted). + // Zero-complete means this line is intentionally closed without inventory posting (noLot / batch close). + refreshWorkbenchPickOrderProgressAfterSolChange(sol.id!!) + val mapped = stockOutLIneRepository.findStockOutLineInfoById(sol.id!!) + return MessageResponse( + id = sol.id, + name = lotNo, + code = "SUCCESS", + type = "workbench_scan_pick", + message = "Workbench SOL completed with qty=0 (no inventory posting)", + errorPosition = null, + entity = mapped, + ) + } + + if (lotNo.isEmpty()) { + return MessageResponse( + id = null, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "lotNo is required", errorPosition = null, entity = null, + ) + } + + val sol = stockOutLIneRepository.findById(request.stockOutLineId).orElse(null) + ?: return MessageResponse( + id = request.stockOutLineId, name = "scan_pick", code = "NOT_FOUND", type = "workbench_scan_pick", + message = "Stock out line not found", errorPosition = null, entity = null, + ) + + val st = sol.status?.trim()?.lowercase() ?: "" + if (st == "completed" || st == StockOutLineStatus.COMPLETE.status.lowercase()) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Stock out line already completed", errorPosition = null, entity = null, + ) + } + if (st == "rejected" || st == StockOutLineStatus.REJECTED.status.lowercase()) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Stock out line is rejected", errorPosition = null, entity = null, + ) + } + + val pol = sol.pickOrderLine + ?: return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Pick order line is missing", errorPosition = null, entity = null, + ) + val item = pol.item + ?: return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Item is missing on pick order line", errorPosition = null, entity = null, + ) + val itemId = item.id!! + val polId = pol.id!! + + val scanIllResolved = + if (request.inventoryLotLineId != null && request.inventoryLotLineId!! > 0) { + resolveInventoryLotLineByIdForWorkbenchScan( + inventoryLotLineId = request.inventoryLotLineId!!, + itemId = itemId, + lotNoTrimmed = lotNo, + ) + } else { + resolveWorkbenchScannedInventoryLotLine( + stockInLineId = request.stockInLineId, + lotNoTrimmed = lotNo, + itemId = itemId, + sol = sol, + ) + } + val scannedIll = when (scanIllResolved) { + is WorkbenchScanIllResolve.Found -> scanIllResolved.ill + is WorkbenchScanIllResolve.NotFound -> return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = scanIllResolved.message, + errorPosition = null, entity = null, + ) + } + + if (scannedIll.deleted) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Lot line is deleted", errorPosition = null, entity = null, + ) + println("Lot line is deleted") + } + val expiry = scannedIll.inventoryLot?.expiryDate + if (expiry != null && expiry.isBefore(LocalDate.now())) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Lot is expired (expiry=$expiry)", errorPosition = null, entity = null, + ) + println("Lot is expired (expiry=$expiry)") + } + if (scannedIll.status?.value == InventoryLotLineStatus.UNAVAILABLE.value) { + return MessageResponse( + id = sol.id, + name = "scan_pick", + code = "INVALID", + type = "workbench_scan_pick", + message = "Lot line is unavailable", + errorPosition = null, + entity = null, + ) + println("Lot line is unavailable") + } + if (scannedIll.inventoryLot?.item?.id != itemId) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Scanned lot does not match pick line item", errorPosition = null, entity = null, + ) + println("Scanned lot does not match pick line item") + } + + sol.inventoryLotLine = scannedIll + + val required = pol.qty ?: BigDecimal.ZERO + val infos = stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) + val endedSumOthers = infos + .filter { WorkbenchStockOutLinePickProgress.isCountedAsPicked(it) && it.id != sol.id } + .fold(BigDecimal.ZERO) { acc, i -> acc + BigDecimal(i.qty?.toString() ?: "0") } + val currentQtyBd = BigDecimal(sol.qty?.toString() ?: "0") + val remainingPol = required.subtract(endedSumOthers).subtract(currentQtyBd).coerceAtLeast(BigDecimal.ZERO) + + val splForLot = suggestPickLotRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) + .firstOrNull { it.suggestedLotLine?.id == scannedIll.id } + val chunkTarget = when { + splForLot?.qty != null && splForLot.qty!! > BigDecimal.ZERO -> + splForLot.qty!!.min(remainingPol.add(currentQtyBd)) + else -> remainingPol.add(currentQtyBd) + } + val stillNeedOnThisSol = chunkTarget.subtract(currentQtyBd).coerceAtLeast(BigDecimal.ZERO) + val hasExplicitQty = request.qty != null + // Implicit (null qty) path uses chunkTarget to enforce planning quantities. + if (!hasExplicitQty && stillNeedOnThisSol <= BigDecimal.ZERO) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "No remaining quantity to pick on this stock out line", errorPosition = null, entity = null, + ) + } + val requestedQtyRaw = if (hasExplicitQty) request.qty!!.coerceAtLeast(BigDecimal.ZERO) else stillNeedOnThisSol + // Explicit qty: do NOT cap by POL; only cap by lot availability (plannedDelta). This enables over-pick vs POL planning qty. + val requestedCap = requestedQtyRaw + if (!hasExplicitQty && requestedCap <= BigDecimal.ZERO) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Pick quantity must be positive", errorPosition = null, entity = null, + ) + } + + val isUserShortPick = false // explicit qty closes SOL; implicit qty uses chunkTarget completion rules + + val illId = scannedIll.id!! + val freshIll = inventoryLotLineRepository.findById(illId).orElse(scannedIll) + val availablePickable = (freshIll.inQty ?: BigDecimal.ZERO) + .subtract(freshIll.outQty ?: BigDecimal.ZERO) + val plannedDelta = requestedCap.min(availablePickable) + // For explicit qty, allow 0 (close SOL without picking); for implicit qty, 0 means conflict/no stock. + if (!hasExplicitQty && plannedDelta <= BigDecimal.ZERO) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "CONFLICT", type = "workbench_scan_pick", + message = "Insufficient available quantity on lot (may have been picked by another user)", + errorPosition = null, entity = null, + ) + } + + // Only the implicit (auto-fill) path uses lot-exhausted split behaviour. + val isLotExhaustedSplit = !hasExplicitQty && + !isUserShortPick && + plannedDelta < stillNeedOnThisSol && + plannedDelta.compareTo(availablePickable) == 0 && + requestedCap >= stillNeedOnThisSol + + // Explicit qty split: if requested exceeds lot availability, allocate remainder to next SPL/SOL. + // var explicitRemainder = BigDecimal.ZERO + + // ----- keep your existing code above ----- + +val solSnapshot = infos.joinToString("; ") { info -> + "sol${info.id} st=${info.status} qty=${info.qty} picked=${WorkbenchStockOutLinePickProgress.isCountedAsPicked(info)}" +} +log.info( + "WORKBENCH_SCAN_TRACE polId={} solId={} scanLotNo={} scanIllId={} polRequired={} [{}] endedSumOthers={} currentSolQty={} remainingPol={} splMatchQty={} chunkTarget={} stillNeedOnThisSol={} requestedCap={} availScanLot={} plannedDelta={} lotSplit={}", + polId, + sol.id, + lotNo, + scannedIll.id, + required, + solSnapshot, + endedSumOthers, + currentQtyBd, + remainingPol, + splForLot?.qty, + chunkTarget, + stillNeedOnThisSol, + requestedCap, + availablePickable, + plannedDelta, + isLotExhaustedSplit, // initial trace only +) + +val prepMs = lapMs() + +// retry-related state +var effectiveDelta = plannedDelta +var updated = 0 +val maxRetries = 2 +var attempt = 0 +var effectiveAvailablePickable = availablePickable + +while (attempt <= maxRetries) { + val latestIll = inventoryLotLineRepository.findById(illId).orElse(scannedIll) + val latestAvailablePickable = (latestIll.inQty ?: BigDecimal.ZERO) + .subtract(latestIll.outQty ?: BigDecimal.ZERO) + effectiveAvailablePickable = latestAvailablePickable + + // Recompute by latest stock each attempt + effectiveDelta = requestedCap.min(latestAvailablePickable) + + if (effectiveDelta <= BigDecimal.ZERO) { + return MessageResponse( + id = sol.id, + name = "scan_pick", + code = "CONFLICT", + type = "workbench_scan_pick", + message = "Insufficient available quantity on lot (may have been picked by another user)", + errorPosition = null, + entity = null, + ) + } + + val traceId = java.util.UUID.randomUUID().toString().substring(0, 8) + val expectedVersion = latestIll.version ?: 0 + + val connIdBefore = jdbcTemplate.queryForObject("SELECT CONNECTION_ID()", Long::class.java) + val beforeRow = jdbcTemplate.queryForMap( + "SELECT id, inQty, outQty, status, version FROM inventory_lot_line WHERE id = ?", + illId + ) + log.info( + "WB_SQL_TRACE phase=before traceId={} connId={} illId={} inQty={} outQty={} status={} ver={} delta={} expVer={} retryAttempt={}", + traceId, connIdBefore, illId, + beforeRow["inQty"], beforeRow["outQty"], beforeRow["status"], beforeRow["version"], + effectiveDelta, expectedVersion, attempt + ) + + updated = inventoryLotLineRepository.incrementOutQtyIfAvailable(illId, effectiveDelta, expectedVersion) + println("updated: $updated") + + val connIdAfter = jdbcTemplate.queryForObject("SELECT CONNECTION_ID()", Long::class.java) + val afterRow = jdbcTemplate.queryForMap( + "SELECT id, inQty, outQty, status, version FROM inventory_lot_line WHERE id = ?", + illId + ) + log.info( + "WB_SQL_TRACE phase=after traceId={} connId={} illId={} updated={} inQty={} outQty={} status={} ver={} retryAttempt={}", + traceId, connIdAfter, illId, updated, + afterRow["inQty"], afterRow["outQty"], afterRow["status"], afterRow["version"], attempt + ) + + if (updated > 0) break + + attempt++ + if (attempt <= maxRetries) { + Thread.sleep(15L) + } +} + +if (updated == 0) { + return MessageResponse( + id = sol.id, + name = "scan_pick", + code = "CONFLICT", + type = "workbench_scan_pick", + message = "庫存或版本已變更,請重掃或重算(Concurrent pick or stock changed)", + errorPosition = null, + entity = null, + ) +} + +val outboundMs = lapMs() + +val newQtyBd = currentQtyBd.add(effectiveDelta) + +// Recompute split/remainder from final successful delta +val effectiveLotExhaustedSplit = !hasExplicitQty && + !isUserShortPick && + effectiveDelta < stillNeedOnThisSol && + effectiveDelta.compareTo(effectiveAvailablePickable) == 0 && + requestedCap >= stillNeedOnThisSol + +val explicitRemainder = if (hasExplicitQty) { + requestedCap.subtract(effectiveDelta).coerceAtLeast(BigDecimal.ZERO) +} else { + BigDecimal.ZERO +} + +val solEndStatus: String = when { + hasExplicitQty -> StockOutLineStatus.COMPLETE.status + effectiveLotExhaustedSplit || isUserShortPick -> StockOutLineStatus.COMPLETE.status + else -> { + val doneThisRow = newQtyBd.compareTo(chunkTarget) >= 0 + if (doneThisRow) StockOutLineStatus.COMPLETE.status + else StockOutLineStatus.PARTIALLY_COMPLETE.status + } +} + +sol.qty = newQtyBd.toDouble() +sol.status = solEndStatus +sol.pickTime = LocalDateTime.now() +sol.handledBy = request.userId +if (sol.startTime == null) sol.startTime = LocalDateTime.now() +if (solEndStatus.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true)) { + sol.endTime = LocalDateTime.now() +} +stockOutLIneRepository.save(sol) +stockOutLIneRepository.flush() +sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkbenchPick(it) } +val saveSolMs = lapMs() + +val pickOrderId = pol.pickOrder?.id + +var rebuildMs = 0L +var ensureMs = 0L +var polPartialMs = 0L +var postMs = 0L + +if (pickOrderId != null) { + if (hasExplicitQty) { + rebuildMs = measureTimeMillis { + if (explicitRemainder > BigDecimal.ZERO) { + suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot( + pickOrderLineId = polId, + targetQty = explicitRemainder, + storeId = request.storeId, + excludeInventoryLotLineId = scannedIll.id, + excludeWarehouseCodes = request.excludeWarehouseCodes, + ) + } else { + suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineExactQty( + pickOrderLineId = polId, + targetQty = BigDecimal.ZERO, + excludeWarehouseCodes = request.excludeWarehouseCodes, + ) + } + } + ensureMs = measureTimeMillis { + if (explicitRemainder > BigDecimal.ZERO) { + stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderLineNoHold(polId, request.userId) + } else { + val allSolEntities = + stockOutLIneRepository.findAllByPickOrderLineIdInAndDeletedFalse(listOf(polId)) + val toClose = allSolEntities.filter { it.id != sol.id && !isWorkbenchSolEndStatus(it.status) } + if (toClose.isNotEmpty()) { + toClose.forEach { s -> + s.status = StockOutLineStatus.COMPLETE.status + if (s.startTime == null) s.startTime = LocalDateTime.now() + s.endTime = LocalDateTime.now() + } + stockOutLIneRepository.saveAll(toClose) + stockOutLIneRepository.flush() + } + } + } + } else { + rebuildMs = measureTimeMillis { + suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( + pickOrderLineId = polId, + storeId = request.storeId, + excludeWarehouseCodes = request.excludeWarehouseCodes, + ) + } + ensureMs = measureTimeMillis { + stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderLineNoHold(polId, request.userId) + } + } + + if (effectiveLotExhaustedSplit) { + polPartialMs = measureTimeMillis { + val polEntity = pickOrderLineRepository.findById(polId).orElse(null) + if (polEntity != null) { + polEntity.status = PickOrderLineStatus.PARTIALLY_COMPLETE + pickOrderLineRepository.save(polEntity) + } + } + } + + updateJoPickOrderHandledByIfJobOrder( + pickOrderId = pickOrderId, + itemId = itemId, + userId = request.userId, + ) +} + +postMs = measureTimeMillis { postWorkbenchPickSideEffects(sol, effectiveDelta) } + +val mapFetchT0 = System.nanoTime() +val mapped = stockOutLIneRepository.findStockOutLineInfoById(sol.id!!) +val mapFetchMs = (System.nanoTime() - mapFetchT0) / 1_000_000 + +val totalMs = (System.nanoTime() - wall0) / 1_000_000 +log.info( + "workbench scanPick timing (ms): total={} prep={} outbound={} saveSol={} rebuildSpl={} ensureSol={} polPartial={} postEffects={} mapFetch={} lotSplit={} solId={} polId={} poId={}", + totalMs, + prepMs, + outboundMs, + saveSolMs, + rebuildMs, + ensureMs, + polPartialMs, + postMs, + mapFetchMs, + effectiveLotExhaustedSplit, + sol.id, + polId, + pickOrderId ?: 0L, +) + +return MessageResponse( + id = sol.id, + name = scannedIll.inventoryLot?.lotNo ?: lotNo, + code = "SUCCESS", + type = "workbench_scan_pick", + message = if (effectiveLotExhaustedSplit) { + "Workbench pick posted; remaining quantity allocated to next stock-out line" + } else { + "Workbench pick posted" + }, + errorPosition = null, + entity = mapped, +) + } + + /** + * Lane summary for DO workbench: one card per **delivery_order_pick_order** ticket (not per pick_order). + * `unassigned` = ticket still has at least one linked pick_order with assignTo null. + */ + open fun getDeliveryOrderPickOrderSummaryByStore(storeId: String, requiredDate: LocalDate?, releaseType: String): StoreLaneSummary { + val targetDate = requiredDate ?: LocalDate.now() + val actualStoreId = when (storeId) { + "2/F" -> "2/F" + "4/F" -> "4/F" + else -> storeId + } + val rt = releaseType.lowercase() + val releaseFilterClause = when (rt) { + "batch" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'batch' " + "single" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'single' " + else -> "" + } + val sql = """ + SELECT + dop.truckDepartureTime AS truckDepartureTime, + dop.truckLanceCode AS truckLanceCode, + dop.loadingSequence AS loadingSequence, + COUNT(DISTINCT dop.id) AS total_cnt, + SUM( + CASE WHEN EXISTS ( + SELECT 1 FROM fpsmsdb.pick_order po2 + WHERE po2.deliveryOrderPickOrderId = dop.id + AND po2.deleted = 0 + AND po2.assignTo IS NULL + ) THEN 1 ELSE 0 END + ) AS unassigned_cnt, + GROUP_CONCAT( + DISTINCT NULLIF(TRIM(dop.handlerName), '') + ORDER BY dop.handlerName + SEPARATOR ', ' + ) AS handler_names + FROM fpsmsdb.delivery_order_pick_order dop + WHERE dop.deleted = 0 + AND dop.storeId = :storeId + AND dop.requiredDeliveryDate = :requiredDate + AND dop.ticketStatus IN ('pending', 'released', 'completed') + AND EXISTS ( + SELECT 1 FROM fpsmsdb.pick_order po + WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + ) + $releaseFilterClause + GROUP BY dop.truckDepartureTime, dop.truckLanceCode, dop.loadingSequence +""".trimIndent() + val params = mapOf( + "storeId" to actualStoreId, + "requiredDate" to targetDate, + ) + val rawRows: List> = try { + jdbcDao.queryForList(sql, params) + } catch (e: Exception) { + println("❌ getDeliveryOrderPickOrderSummaryByStore: ${e.message}") + emptyList() + } + + val defaultTruck = truckRepository.findById(5577L).orElse(null) + val defaultTruckLaneCode = defaultTruck?.truckLanceCode ?: "" + + data class LaneAgg( + val sortTime: LocalTime, + val lance: String, + val loadingSequence: Int?, + val unassigned: Int, + val total: Int, + val handlerName: String?, // 改成可空,名稱也統一 + ) + + fun cellStr(row: Map, name: String): String? { + val k = row.keys.find { it.equals(name, true) } ?: return null + return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + } + + fun cellNum(row: Map, vararg names: String): Int { + for (n in names) { + val k = row.keys.find { it.equals(n, true) } ?: continue + (row[k] as? Number)?.toInt()?.let { return it } + } + return 0 + } + + fun cellNullableInt(row: Map, vararg names: String): Int? { + for (n in names) { + val k = row.keys.find { it.equals(n, true) } ?: continue + val v = row[k] ?: continue + when (v) { + is Number -> return v.toInt() + is String -> v.trim().toIntOrNull()?.let { return it } + } + } + return null + } + + fun cellTime(row: Map): LocalTime? { + val k = row.keys.find { it.equals("truckDepartureTime", true) } ?: return null + val v = row[k] ?: return null + return when (v) { + is java.sql.Time -> v.toLocalTime() + is LocalTime -> v + is java.sql.Timestamp -> v.toLocalDateTime().toLocalTime() + else -> runCatching { LocalTime.parse(v.toString().take(8)) }.getOrNull() + ?: runCatching { LocalTime.parse(v.toString()) }.getOrNull() + } + } + + val laneAggs = rawRows.mapNotNull { row -> + val lance = cellStr(row, "truckLanceCode") ?: return@mapNotNull null + if (lance == defaultTruckLaneCode) return@mapNotNull null + val loadingSequence = cellNullableInt(row, "loadingSequence") + val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt") + val total = cellNum(row, "total_cnt", "totalCnt") + if (unassigned <= 0) return@mapNotNull null + val sortTime = cellTime(row) ?: LocalTime.MIDNIGHT + val handlerName = cellStr(row, "handler_names") + LaneAgg(sortTime, lance, loadingSequence, unassigned, total, handlerName) + } + + // 2/F:與 [DoPickOrderQueryService.getSummaryByStore] 一致,只按「出發時間 + 車線」合併,不按 loadingSequence 拆開。 + // 否則同一車線會出現多顆 (1/1) 按鈕,與 normal FG 畫面不一致。 + val laneAggsForUi = if (actualStoreId == "2/F") { + laneAggs + .groupBy { it.sortTime to it.lance } + .values + .map { group -> + val head = group.first() + val mergedHandlers = group + .mapNotNull { it.handlerName?.trim()?.takeIf { n -> n.isNotEmpty() } } + .flatMap { s -> s.split(",").map { it.trim() }.filter { it.isNotEmpty() } } + .distinct() + .sorted() + .joinToString(", ") + .takeIf { it.isNotEmpty() } + LaneAgg( + sortTime = head.sortTime, + lance = head.lance, + loadingSequence = null, + unassigned = group.sumOf { it.unassigned }, + total = group.sumOf { it.total }, + handlerName = mergedHandlers, + ) + } + } else { + laneAggs + } + + val groupedByTime = laneAggsForUi.groupBy { it.sortTime } + val sortedTimes = groupedByTime.keys.sorted() + val timeGroups = sortedTimes.take(4).map { st -> + val items = groupedByTime[st].orEmpty() + LaneRow( + truckDepartureTime = st.toString(), + lanes = items + .map { + LaneBtn( + truckLanceCode = it.lance, + loadingSequence = if (actualStoreId == "4/F") it.loadingSequence else null, + unassigned = it.unassigned, + total = it.total, + handlerName = it.handlerName, + ) + } + .sortedWith( + compareByDescending { it.unassigned } + .thenBy { it.truckLanceCode } + .thenBy { it.loadingSequence ?: 999 } + ) + ) + } + + val defaultCountSql = """ + SELECT COUNT(DISTINCT dop.id) AS c + FROM fpsmsdb.delivery_order_pick_order dop + WHERE dop.deleted = 0 + AND dop.requiredDeliveryDate = :requiredDate + AND dop.ticketStatus IN ('pending', 'released') + AND (dop.storeId IS NULL OR TRIM(dop.storeId) = '') + AND dop.truckLanceCode = '車線-X' + AND EXISTS ( + SELECT 1 FROM fpsmsdb.pick_order po + WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + ) + $releaseFilterClause + """.trimIndent() + + val defaultTruckCount = try { + val r: List> = jdbcDao.queryForList(defaultCountSql, mapOf("requiredDate" to targetDate)) + val first: Map? = r.firstOrNull() + if (first == null) 0 + else (first.values.firstOrNull() as? Number)?.toInt() ?: 0 + } catch (_: Exception) { + 0 + } + + return StoreLaneSummary( + storeId = storeId, + rows = timeGroups, + defaultTruckCount = defaultTruckCount + ) + } + + open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( + shopName: String?, + storeId: String?, + truck: String?, + ): List = + queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true) + + open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( + shopName: String?, + storeId: String?, + truck: String?, + ): List = + queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = false) + + /** + * Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`. + * Scope is "mine": any linked pick_order assigned to the given user. + * + * Returned shape aligns with legacy [CompletedDoPickOrderResponse] so UI can reuse record list. + * Workbench has no do_pick_order_record id, so `doPickOrderRecordId`/`recordId` use the DOPO id. + */ + open fun getCompletedDoPickOrdersWorkbench( + userId: Long, + request: GetCompletedDoPickOrdersRequest, + ): List { + val targetDate: LocalDate? = request.targetDate + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + + val params = mutableMapOf( + "userId" to userId, + ) + + val sql = StringBuilder( + """ + SELECT + dop.id AS id, + dop.ticketNo AS ticketNo, + dop.storeId AS storeId, + dop.shopCode AS shopCode, + dop.shopName AS shopName, + dop.truckLanceCode AS truckLanceCode, + dop.truckDepartureTime AS departureTime, + dop.requiredDeliveryDate AS requiredDeliveryDate, + dop.modified AS completedAt, + dop.deliveryNoteCode AS deliveryNoteCode, + dop.cartonQty AS cartonQty, + dop.handlerName AS handlerName, + GROUP_CONCAT(DISTINCT po.id ORDER BY po.id SEPARATOR ',') AS pickOrderIdsStr, + GROUP_CONCAT(DISTINCT po.code ORDER BY po.code SEPARATOR ',') AS pickOrderCodesStr, + GROUP_CONCAT(DISTINCT d.id ORDER BY d.id SEPARATOR ',') AS deliveryOrderIdsStr, + GROUP_CONCAT(DISTINCT d.code ORDER BY d.code SEPARATOR ',') AS deliveryNosStr, + MIN(po.consoCode) AS consoCode + FROM fpsmsdb.delivery_order_pick_order dop + INNER JOIN fpsmsdb.pick_order po + ON po.deliveryOrderPickOrderId = dop.id + AND po.deleted = 0 + LEFT JOIN fpsmsdb.delivery_order d + ON d.id = po.doId + AND d.deleted = 0 + WHERE dop.deleted = 0 + AND dop.ticketStatus = 'completed' + AND po.assignTo = :userId + """.trimIndent() + ) + + if (targetDate != null) { + sql.append(" AND dop.requiredDeliveryDate = :targetDate ") + params["targetDate"] = targetDate + } + if (!request.shopName.isNullOrBlank()) { + sql.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ") + params["shopPat"] = "%${request.shopName!!.trim()}%" + } + if (!request.truckLanceCode.isNullOrBlank()) { + sql.append(" AND dop.truckLanceCode LIKE :truckPat ") + params["truckPat"] = "%${request.truckLanceCode!!.trim()}%" + } + if (!request.deliveryNoteCode.isNullOrBlank()) { + sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ") + params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%" + } + + sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ") + + val rows: List> = try { + jdbcDao.queryForList(sql.toString(), params) + } catch (e: Exception) { + println("❌ getCompletedDoPickOrdersWorkbench: ${e.message}") + emptyList() + } + + fun str(row: Map, key: String): String? { + val k = row.keys.find { it.equals(key, true) } ?: return null + return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + } + + fun parseIds(csv: String?): List = + csv?.split(",") + ?.mapNotNull { it.trim().takeIf { s -> s.isNotEmpty() }?.toLongOrNull() } + ?: emptyList() + + fun parseStrs(csv: String?): List = + csv?.split(",") + ?.mapNotNull { it.trim().takeIf { s -> s.isNotEmpty() } } + ?: emptyList() + + val dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + return rows.mapNotNull { row -> + val id = (row["id"] as? Number)?.toLong() ?: return@mapNotNull null + val ticketNo = str(row, "ticketNo") ?: "" + val storeId = str(row, "storeId") + val shopCode = str(row, "shopCode") + val shopName = str(row, "shopName") + val truckLanceCode = str(row, "truckLanceCode") + val dep: LocalTime? = row[row.keys.find { it.equals("departureTime", true) } ?: "departureTime"]?.let { v -> + when (v) { + is java.sql.Time -> v.toLocalTime() + is LocalTime -> v + else -> runCatching { LocalTime.parse(v.toString().take(8)) }.getOrNull() + } + } + val requiredDateStr = str(row, "requiredDeliveryDate") + val completedAtStr = row[row.keys.find { it.equals("completedAt", true) } ?: "completedAt"]?.let { v -> + when (v) { + is LocalDateTime -> v.format(dtf) + is java.sql.Timestamp -> v.toLocalDateTime().format(dtf) + else -> v.toString() + } + } + + val pickOrderIds = parseIds(str(row, "pickOrderIdsStr")) + val pickOrderCodes = parseStrs(str(row, "pickOrderCodesStr")) + val deliveryOrderIds = parseIds(str(row, "deliveryOrderIdsStr")) + val deliveryNos = parseStrs(str(row, "deliveryNosStr")) + val deliveryNoteCode = str(row, "deliveryNoteCode") + val cartonQty = (row[row.keys.find { it.equals("cartonQty", true) } ?: "cartonQty"] as? Number)?.toInt() ?: 0 + + CompletedDoPickOrderResponse( + id = id, + doPickOrderRecordId = id, + recordId = id, + ticketNo = ticketNo, + storeId = storeId, + completedDate = completedAtStr, + shopCode = shopCode, + shopName = shopName, + deliveryNoteCode = deliveryNoteCode, + truckLanceCode = truckLanceCode, + departureTime = dep, + pickOrderIds = pickOrderIds, + pickOrderCodes = pickOrderCodes, + deliveryOrderIds = deliveryOrderIds, + deliveryNos = deliveryNos, + pickOrderConsoCode = str(row, "consoCode"), + pickOrderStatus = "completed", + deliveryDate = requiredDateStr, + numberOfCartons = cartonQty, + shopId = null, + shopAddress = null, + fgPickOrders = emptyList(), + handlerName = str(row, "handlerName"), + ) + } + } + + /** + * Workbench completed tickets (all users): + * query `delivery_order_pick_order` where `ticketStatus = completed`. + */ + open fun getCompletedDoPickOrdersWorkbenchAll( + request: GetCompletedDoPickOrdersRequest, + ): List { + val targetDate: LocalDate? = request.targetDate + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + + val params = mutableMapOf() + + val sql = StringBuilder( + """ + SELECT + dop.id AS id, + dop.ticketNo AS ticketNo, + dop.storeId AS storeId, + dop.shopCode AS shopCode, + dop.shopName AS shopName, + dop.truckLanceCode AS truckLanceCode, + dop.truckDepartureTime AS departureTime, + dop.requiredDeliveryDate AS requiredDeliveryDate, + dop.modified AS completedAt, + dop.deliveryNoteCode AS deliveryNoteCode, + dop.cartonQty AS cartonQty, + dop.handlerName AS handlerName, + GROUP_CONCAT(DISTINCT po.id ORDER BY po.id SEPARATOR ',') AS pickOrderIdsStr, + GROUP_CONCAT(DISTINCT po.code ORDER BY po.code SEPARATOR ',') AS pickOrderCodesStr, + GROUP_CONCAT(DISTINCT d.id ORDER BY d.id SEPARATOR ',') AS deliveryOrderIdsStr, + GROUP_CONCAT(DISTINCT d.code ORDER BY d.code SEPARATOR ',') AS deliveryNosStr, + MIN(po.consoCode) AS consoCode + FROM fpsmsdb.delivery_order_pick_order dop + INNER JOIN fpsmsdb.pick_order po + ON po.deliveryOrderPickOrderId = dop.id + AND po.deleted = 0 + LEFT JOIN fpsmsdb.delivery_order d + ON d.id = po.doId + AND d.deleted = 0 + WHERE dop.deleted = 0 + AND dop.ticketStatus = 'completed' + """.trimIndent() + ) + + if (targetDate != null) { + sql.append(" AND dop.requiredDeliveryDate = :targetDate ") + params["targetDate"] = targetDate + } + if (!request.shopName.isNullOrBlank()) { + sql.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ") + params["shopPat"] = "%${request.shopName!!.trim()}%" + } + if (!request.truckLanceCode.isNullOrBlank()) { + sql.append(" AND dop.truckLanceCode LIKE :truckPat ") + params["truckPat"] = "%${request.truckLanceCode!!.trim()}%" + } + if (!request.deliveryNoteCode.isNullOrBlank()) { + sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ") + params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%" + } + + sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ") + + val rows: List> = try { + jdbcDao.queryForList(sql.toString(), params) + } catch (e: Exception) { + println("❌ getCompletedDoPickOrdersWorkbenchAll: ${e.message}") + emptyList() + } + + fun str(row: Map, key: String): String? { + val k = row.keys.find { it.equals(key, true) } ?: return null + return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + } + + fun parseIds(csv: String?): List = + csv?.split(",") + ?.mapNotNull { it.trim().takeIf { s -> s.isNotEmpty() }?.toLongOrNull() } + ?: emptyList() + + fun parseStrs(csv: String?): List = + csv?.split(",") + ?.mapNotNull { it.trim().takeIf { s -> s.isNotEmpty() } } + ?: emptyList() + + val dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + return rows.mapNotNull { row -> + val id = (row["id"] as? Number)?.toLong() ?: return@mapNotNull null + val ticketNo = str(row, "ticketNo") ?: "" + val storeId = str(row, "storeId") + val shopCode = str(row, "shopCode") + val shopName = str(row, "shopName") + val truckLanceCode = str(row, "truckLanceCode") + val dep: LocalTime? = row[row.keys.find { it.equals("departureTime", true) } ?: "departureTime"]?.let { v -> + when (v) { + is java.sql.Time -> v.toLocalTime() + is LocalTime -> v + else -> runCatching { LocalTime.parse(v.toString().take(8)) }.getOrNull() + } + } + val requiredDateStr = str(row, "requiredDeliveryDate") + val completedAtStr = row[row.keys.find { it.equals("completedAt", true) } ?: "completedAt"]?.let { v -> + when (v) { + is LocalDateTime -> v.format(dtf) + is java.sql.Timestamp -> v.toLocalDateTime().format(dtf) + else -> v.toString() + } + } + + val pickOrderIds = parseIds(str(row, "pickOrderIdsStr")) + val pickOrderCodes = parseStrs(str(row, "pickOrderCodesStr")) + val deliveryOrderIds = parseIds(str(row, "deliveryOrderIdsStr")) + val deliveryNos = parseStrs(str(row, "deliveryNosStr")) + val deliveryNoteCode = str(row, "deliveryNoteCode") + val cartonQty = (row[row.keys.find { it.equals("cartonQty", true) } ?: "cartonQty"] as? Number)?.toInt() ?: 0 + + CompletedDoPickOrderResponse( + id = id, + doPickOrderRecordId = id, + recordId = id, + ticketNo = ticketNo, + storeId = storeId, + completedDate = completedAtStr, + shopCode = shopCode, + shopName = shopName, + deliveryNoteCode = deliveryNoteCode, + truckLanceCode = truckLanceCode, + departureTime = dep, + pickOrderIds = pickOrderIds, + pickOrderCodes = pickOrderCodes, + deliveryOrderIds = deliveryOrderIds, + deliveryNos = deliveryNos, + pickOrderConsoCode = str(row, "consoCode"), + pickOrderStatus = "completed", + deliveryDate = requiredDateStr, + numberOfCartons = cartonQty, + shopId = null, + shopAddress = null, + fgPickOrders = emptyList(), + handlerName = str(row, "handlerName"), + ) + } + } + + /** + * Workbench ticket release table: + * source of truth is `delivery_order_pick_order` (+ linked `pick_order` / `pick_order_line`). + * This API is intentionally independent from legacy `do_pick_order` ticket table. + */ + open fun getWorkbenchTicketReleaseTable( + startDate: LocalDate? = null, + endDate: LocalDate? = null, + ): List { + val params = mutableMapOf() + val dateClause = if (startDate != null && endDate != null) { + params["startDate"] = startDate + params["endDate"] = endDate + " AND dop.requiredDeliveryDate BETWEEN :startDate AND :endDate " + } else { + "" + } + val rows = jdbcDao.queryForList( + """ + SELECT + dop.id AS deliveryOrderPickOrderId, + dop.storeId AS storeId, + dop.ticketNo AS ticketNo, + dop.loadingSequence AS loadingSequence, + dop.ticketStatus AS ticketStatus, + dop.truckDepartureTime AS truckDepartureTime, + dop.handledBy AS handledBy, + dop.ticketReleaseTime AS ticketReleaseTime, + dop.ticketCompleteDateTime AS ticketCompleteDateTime, + dop.truckLanceCode AS truckLanceCode, + dop.shopCode AS shopCode, + dop.shopName AS shopName, + dop.requiredDeliveryDate AS requiredDeliveryDate, + dop.handlerName AS handlerName, + COALESCE(SUM(polCount.cnt), 0) AS numberOfFGItems + FROM fpsmsdb.delivery_order_pick_order dop + LEFT JOIN ( + SELECT + po.deliveryOrderPickOrderId AS dopId, + po.id AS pickOrderId, + COUNT(pol.id) AS cnt + FROM fpsmsdb.pick_order po + LEFT JOIN fpsmsdb.pick_order_line pol + ON pol.poId = po.id + AND pol.deleted = 0 + WHERE po.deleted = 0 + GROUP BY po.deliveryOrderPickOrderId, po.id + ) polCount ON polCount.dopId = dop.id + WHERE dop.deleted = 0 + $dateClause + GROUP BY + dop.id, dop.storeId, dop.ticketNo, dop.loadingSequence, dop.ticketStatus, + dop.truckDepartureTime, dop.handledBy, dop.ticketReleaseTime, dop.ticketCompleteDateTime, + dop.truckLanceCode, dop.shopCode, dop.shopName, dop.requiredDeliveryDate, dop.handlerName + ORDER BY COALESCE(dop.ticketReleaseTime, dop.modified, dop.created) DESC + """.trimIndent(), + params, + ) + + fun str(row: Map, key: String): String? { + val k = row.keys.find { it.equals(key, true) } ?: return null + return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + } + fun longVal(row: Map, key: String): Long? { + val k = row.keys.find { it.equals(key, true) } ?: return null + return (row[k] as? Number)?.toLong() + } + fun intVal(row: Map, key: String): Int? { + val k = row.keys.find { it.equals(key, true) } ?: return null + return (row[k] as? Number)?.toInt() + } + fun localDateVal(row: Map, key: String): LocalDate? { + val k = row.keys.find { it.equals(key, true) } ?: return null + val v = row[k] ?: return null + return when (v) { + is LocalDate -> v + is java.sql.Date -> v.toLocalDate() + else -> runCatching { LocalDate.parse(v.toString().take(10)) }.getOrNull() + } + } + fun localTimeVal(row: Map, key: String): LocalTime? { + val k = row.keys.find { it.equals(key, true) } ?: return null + val v = row[k] ?: return null + return when (v) { + is LocalTime -> v + is java.sql.Time -> v.toLocalTime() + else -> runCatching { LocalTime.parse(v.toString().take(8)) }.getOrNull() + } + } + fun localDateTimeVal(row: Map, key: String): LocalDateTime? { + val k = row.keys.find { it.equals(key, true) } ?: return null + val v = row[k] ?: return null + return when (v) { + is LocalDateTime -> v + is java.sql.Timestamp -> v.toLocalDateTime() + else -> runCatching { LocalDateTime.parse(v.toString()) }.getOrNull() + } + } + + return rows.mapNotNull { row -> + val id = longVal(row, "deliveryOrderPickOrderId") ?: return@mapNotNull null + val status = str(row, "ticketStatus") + WorkbenchTicketReleaseTableResponse( + deliveryOrderPickOrderId = id, + storeId = str(row, "storeId"), + ticketNo = str(row, "ticketNo"), + loadingSequence = intVal(row, "loadingSequence"), + ticketStatus = status, + truckDepartureTime = localTimeVal(row, "truckDepartureTime"), + handledBy = longVal(row, "handledBy"), + ticketReleaseTime = localDateTimeVal(row, "ticketReleaseTime"), + ticketCompleteDateTime = localDateTimeVal(row, "ticketCompleteDateTime"), + truckLanceCode = str(row, "truckLanceCode"), + shopCode = str(row, "shopCode"), + shopName = str(row, "shopName"), + requiredDeliveryDate = localDateVal(row, "requiredDeliveryDate"), + handlerName = str(row, "handlerName"), + numberOfFGItems = intVal(row, "numberOfFGItems") ?: 0, + isActiveWorkbenchTicket = !status.equals("completed", ignoreCase = true), + ) + } + } + + @Transactional + open fun forceCompleteWorkbenchTicket(deliveryOrderPickOrderId: Long): MessageResponse { + val dop = deliveryOrderPickOrderRepository.findById(deliveryOrderPickOrderId).orElse(null) + ?: return MessageResponse( + id = null, + code = "NOT_FOUND", + name = null, + type = null, + message = "delivery_order_pick_order not found", + errorPosition = null, + entity = null, + ) + if (dop.deleted) { + return MessageResponse( + id = null, + code = "NOT_FOUND", + name = null, + type = null, + message = "delivery_order_pick_order not found", + errorPosition = null, + entity = null, + ) + } + + val linkedPickOrderIds = pickOrderRepository.findIdsByDeliveryOrderPickOrderId(deliveryOrderPickOrderId) + if (linkedPickOrderIds.isNotEmpty()) { + val linkedPickOrders = pickOrderRepository.findAllById(linkedPickOrderIds) + linkedPickOrders.forEach { po -> + po.assignTo = null + if (po.status != PickOrderStatus.COMPLETED) { + po.status = PickOrderStatus.COMPLETED + po.completeDate = LocalDateTime.now() + } + } + pickOrderRepository.saveAll(linkedPickOrders) + + val linkedPickOrderLines = pickOrderLineRepository.findAllByPickOrderIdInAndDeletedFalse(linkedPickOrderIds) + linkedPickOrderLines.forEach { pol -> + if (pol.status != PickOrderLineStatus.COMPLETED) { + pol.status = PickOrderLineStatus.COMPLETED + } + } + if (linkedPickOrderLines.isNotEmpty()) { + pickOrderLineRepository.saveAll(linkedPickOrderLines) + } + } + + if (dop.ticketStatus != DoPickOrderStatus.completed) { + dop.ticketStatus = DoPickOrderStatus.completed + dop.ticketCompleteDateTime = LocalDateTime.now() + if (dop.deliveryNoteCode.isNullOrBlank()) { + val prefix = "DN" + val midfix = CodeGenerator.DEFAULT_MIDFIX + val codePrefix = "$prefix-$midfix" + val latestLegacyDn = doPickOrderRecordRepository.findLatestDeliveryNoteCodeByPrefix(codePrefix) + val latestWorkbenchDn = deliveryOrderPickOrderRepository.findLatestDeliveryNoteCodeByPrefix(codePrefix) + val latestCode = listOfNotNull(latestLegacyDn, latestWorkbenchDn).maxOrNull() + dop.deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode) + } + deliveryOrderPickOrderRepository.save(dop) + } + + return MessageResponse( + id = dop.id, + code = "SUCCESS", + name = dop.ticketNo, + type = null, + message = "Workbench ticket completed", + errorPosition = null, + entity = mapOf( + "deliveryOrderPickOrderId" to dop.id, + "ticketStatus" to (dop.ticketStatus?.name ?: ""), + "deliveryNoteCode" to dop.deliveryNoteCode, + ), + ) + } + + @Transactional + open fun revertWorkbenchTicketAssignment(deliveryOrderPickOrderId: Long): MessageResponse { + val dop = deliveryOrderPickOrderRepository.findById(deliveryOrderPickOrderId).orElse(null) + ?: return MessageResponse( + id = null, + code = "NOT_FOUND", + name = null, + type = null, + message = "delivery_order_pick_order not found", + errorPosition = null, + entity = null, + ) + if (dop.deleted) { + return MessageResponse( + id = null, + code = "NOT_FOUND", + name = null, + type = null, + message = "delivery_order_pick_order not found", + errorPosition = null, + entity = null, + ) + } + if (dop.ticketStatus == DoPickOrderStatus.completed) { + return MessageResponse( + id = dop.id, + code = "INVALID_STATUS", + name = dop.ticketNo, + type = null, + message = "Cannot revert a completed ticket", + errorPosition = null, + entity = null, + ) + } + if (dop.handledBy == null) { + return MessageResponse( + id = dop.id, + code = "NOT_ASSIGNED", + name = dop.ticketNo, + type = null, + message = "No user assignment to revert", + errorPosition = null, + entity = null, + ) + } + + dop.handledBy = null + dop.handlerName = null + dop.ticketStatus = DoPickOrderStatus.pending + dop.ticketReleaseTime = null + dop.ticketCompleteDateTime = null + deliveryOrderPickOrderRepository.save(dop) + + val now = LocalDateTime.now() + jdbcDao.executeUpdate( + """ + UPDATE fpsmsdb.pick_order po + SET po.assignTo = NULL, + po.status = 'pending', + po.modified = :modified, + po.modifiedBy = :modifiedBy + WHERE po.deliveryOrderPickOrderId = :dopoId + AND po.deleted = 0 + AND po.status <> 'completed' + """.trimIndent(), + mapOf( + "dopoId" to deliveryOrderPickOrderId, + "modified" to now, + "modifiedBy" to "workbench", + ), + ) + + return MessageResponse( + id = dop.id, + code = "SUCCESS", + name = dop.ticketNo, + type = null, + message = "Workbench assignment reverted", + errorPosition = null, + entity = mapOf("deliveryOrderPickOrderId" to dop.id), + ) + } + + private fun queryWorkbenchReleasedDopoList( + shopName: String?, + storeId: String?, + truck: String?, + beforeToday: Boolean, + ): List { + val today = LocalDate.now() + val dateClause = + if (beforeToday) " dop.requiredDeliveryDate < :today " else " dop.requiredDeliveryDate = :today " + val params = mutableMapOf("today" to today) + val sqlBuilder = StringBuilder( + """ + SELECT + dop.id AS id, + dop.requiredDeliveryDate AS requiredDeliveryDate, + dop.shopCode AS shopCode, + dop.shopName AS shopName, + dop.storeId AS storeId, + dop.truckLanceCode AS truckLanceCode, + dop.truckDepartureTime AS truckDepartureTime, + ( + SELECT GROUP_CONCAT(DISTINCT d.code ORDER BY d.code SEPARATOR ',') + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + ) AS doCodesStr + FROM fpsmsdb.delivery_order_pick_order dop + WHERE dop.deleted = 0 + AND dop.ticketStatus IN ('pending', 'released') + AND $dateClause + """.trimIndent() + ) + if (!storeId.isNullOrBlank()) { + val sid = when (storeId) { + "2/F" -> "2/F" + "4/F" -> "4/F" + else -> storeId.trim() + } + sqlBuilder.append(" AND dop.storeId = :storeId ") + params["storeId"] = sid + } + if (!truck.isNullOrBlank()) { + sqlBuilder.append(" AND dop.truckLanceCode = :truck ") + params["truck"] = truck.trim() + } + if (!shopName.isNullOrBlank()) { + sqlBuilder.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ") + params["shopPat"] = "%${shopName.trim()}%" + } + sqlBuilder.append(" ORDER BY dop.requiredDeliveryDate, dop.truckDepartureTime, dop.truckLanceCode, dop.id ") + val rows: List> = try { + jdbcDao.queryForList(sqlBuilder.toString(), params) + } catch (e: Exception) { + println("❌ queryWorkbenchReleasedDopoList: ${e.message}") + emptyList() + } + return rows.mapNotNull { mapRowToReleasedDoPickOrderListItem(it) } + } + + private fun mapRowToReleasedDoPickOrderListItem(row: Map): ReleasedDoPickOrderListItem? { + val idKey = row.keys.find { it.equals("id", true) } ?: return null + val id = (row[idKey] as? Number)?.toLong() ?: return null + val rdKey = row.keys.find { it.equals("requiredDeliveryDate", true) } + val reqDate = when (val v = rdKey?.let { row[it] }) { + null -> null + is java.sql.Date -> v.toLocalDate() + is LocalDate -> v + else -> runCatching { LocalDate.parse(v.toString().take(10)) }.getOrNull() + } + fun strCol(n: String) = row.keys.find { it.equals(n, true) }?.let { row[it]?.toString()?.trim() }?.takeIf { it.isNotEmpty() } + val ttKey = row.keys.find { it.equals("truckDepartureTime", true) } + val ttVal = ttKey?.let { row[it] } + val truckDepartureTime = when (ttVal) { + null -> null + is java.sql.Time -> ttVal.toLocalTime() + is LocalTime -> ttVal + is java.sql.Timestamp -> ttVal.toLocalDateTime().toLocalTime() + else -> runCatching { LocalTime.parse(ttVal.toString().take(8)) }.getOrNull() + ?: runCatching { LocalTime.parse(ttVal.toString()) }.getOrNull() + } + val doCodesStr = strCol("doCodesStr") ?: strCol("docodesstr") + val codes = doCodesStr?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() + return ReleasedDoPickOrderListItem( + id = id, + requiredDeliveryDate = reqDate, + shopCode = strCol("shopCode"), + shopName = strCol("shopName"), + storeId = strCol("storeId"), + truckLanceCode = strCol("truckLanceCode"), + truckDepartureTime = truckDepartureTime, + deliveryOrderCodes = codes, + ) + } + + /** + * DO workbench: header [delivery_order_pick_order] + lines from [pick_order.deliveryOrderPickOrderId] (no do_pick_order_line). + */ + open fun getAllPickOrderLotsWithDetailsHierarchicalWorkbench(userId: Long): Map { + println("=== Debug: getAllPickOrderLotsWithDetailsHierarchicalWorkbench (delivery_order_pick_order) ===") + println("userId filter: $userId") + val wallStartNs = System.nanoTime() + val timing = linkedMapOf() + fun mark(name: String, ms: Long) { + timing[name] = (timing[name] ?: 0L) + ms + } + + val user = userService.find(userId).orElse(null) + if (user == null) { + println("❌ User not found: $userId") + return emptyMap() + } + + val dopSql = """ + SELECT DISTINCT + dop.id as do_pick_order_id, + dop.ticketNo as ticket_no, + dop.storeId as store_id, + dop.truckLanceCode as TruckLanceCode, + dop.truckDepartureTime as truck_departure_time, + dop.shopCode as ShopCode, + dop.shopName as ShopName, + dop.ticketStatus as doTicketStatus + FROM fpsmsdb.delivery_order_pick_order dop + WHERE dop.handledBy = :userId + AND dop.ticketStatus IN ('pending', 'released', 'completed') + AND dop.deleted = 0 + AND EXISTS ( + SELECT 1 + FROM fpsmsdb.pick_order po + WHERE po.deliveryOrderPickOrderId = dop.id + AND po.assignTo = :userId + AND po.type = 'do' + AND po.deleted = 0 + ) + ORDER BY dop.requiredDeliveryDate DESC, dop.truckDepartureTime DESC, dop.id DESC + LIMIT 1 + """.trimIndent() + + var doPickOrderInfo: Map? = null + val dopQueryMs = measureTimeMillis { + doPickOrderInfo = jdbcDao.queryForMap(dopSql, mapOf("userId" to userId)).orElse(null) + } + mark("query.dopMs", dopQueryMs) + if (doPickOrderInfo == null) { + println("❌ No delivery_order_pick_order found for workbench user $userId") + val totalMs = (System.nanoTime() - wallStartNs) / 1_000_000 + log.info( + "workbench all-lots-hierarchical-workbench timing: userId={} totalMs={} detail={}", + userId, + totalMs, + timing.entries.joinToString(", ") { "${it.key}=${it.value}" }, + ) + return mapOf( + "fgInfo" to null, + "pickOrders" to emptyList(), + ) + } + + val doPickOrderInfoSafe = doPickOrderInfo ?: return mapOf( + "fgInfo" to null, + "pickOrders" to emptyList(), + ) + val doTicketStatus = doPickOrderInfoSafe["doTicketStatus"]?.toString()?.trim()?.lowercase() + val doPickOrderId = (doPickOrderInfoSafe["do_pick_order_id"] as? Number)?.toLong() + if (doPickOrderId == null) { + println("❌ Invalid delivery_order_pick_order id for user $userId") + return mapOf( + "fgInfo" to null, + "pickOrders" to emptyList(), + ) + } + println(" Found delivery_order_pick_order ID: $doPickOrderId") + println(" delivery_order_pick_order ticketStatus: $doTicketStatus") + + if (doTicketStatus == "completed") { + return mapOf( + "ticketStatus" to "completed", + "fgInfo" to null, + "pickOrders" to emptyList(), + ) + } + + val pickOrdersSql = """ + SELECT DISTINCT + po.id as pick_order_id, + po.code as pick_order_code, + do_tbl.id as do_order_id, + do_tbl.code as delivery_order_code, + po.consoCode, + po.status, + DATE_FORMAT(po.targetDate, '%Y-%m-%d') as targetDate + FROM fpsmsdb.pick_order po + LEFT JOIN fpsmsdb.delivery_order do_tbl ON do_tbl.id = po.doId + WHERE po.deliveryOrderPickOrderId = :doPickOrderId + AND po.assignTo = :userId + AND po.type = 'do' + AND po.deleted = false + ORDER BY po.id + """.trimIndent() + + var pickOrdersInfo: List> = emptyList() + val pickOrdersQueryMs = measureTimeMillis { + pickOrdersInfo = jdbcDao.queryForList( + pickOrdersSql, + mapOf("doPickOrderId" to doPickOrderId, "userId" to userId), + ) + } + mark("query.pickOrdersMs", pickOrdersQueryMs) + println(" Found ${pickOrdersInfo.size} pick orders (workbench)") + + var payload: Map = emptyMap() + val assembleMs = measureTimeMillis { + payload = assembleHierarchicalFgPayload( + jdbcDao = jdbcDao, + doPickOrderId = doPickOrderId, + doPickOrderInfo = doPickOrderInfoSafe, + pickOrdersInfo = pickOrdersInfo, + availableQtyInOutOnly = true, + timingSink = ::mark, + ) + } + mark("assemble.totalMs", assembleMs) + val totalMs = (System.nanoTime() - wallStartNs) / 1_000_000 + log.info( + "workbench all-lots-hierarchical-workbench timing: userId={} dopoId={} pickOrders={} totalMs={} detail={}", + userId, + doPickOrderId, + pickOrdersInfo.size, + totalMs, + timing.entries.joinToString(", ") { "${it.key}=${it.value}" }, + ) + return payload + mapOf("ticketStatus" to doTicketStatus) + } + + /** + * Completed-record detail for DO workbench. + * Input id is `delivery_order_pick_order.id` (frontend `doPickOrderRecordId` in workbench mode). + */ + open fun getCompletedLotDetailsByDeliveryOrderPickOrderId(deliveryOrderPickOrderId: Long): Map { + val dopSql = """ + SELECT + dop.id as do_pick_order_id, + dop.ticketNo as ticket_no, + dop.storeId as store_id, + dop.truckLanceCode as TruckLanceCode, + dop.truckDepartureTime as truck_departure_time, + dop.shopCode as ShopCode, + dop.shopName as ShopName, + dop.ticketStatus as doTicketStatus + FROM fpsmsdb.delivery_order_pick_order dop + WHERE dop.id = :dopoId + AND dop.deleted = 0 + LIMIT 1 + """.trimIndent() + + val doPickOrderInfo = jdbcDao.queryForMap(dopSql, mapOf("dopoId" to deliveryOrderPickOrderId)).orElse(null) + ?: return mapOf("fgInfo" to null, "pickOrders" to emptyList()) + + val pickOrdersSql = """ + SELECT DISTINCT + po.id as pick_order_id, + po.code as pick_order_code, + do_tbl.id as do_order_id, + do_tbl.code as delivery_order_code, + po.consoCode, + po.status, + DATE_FORMAT(po.targetDate, '%Y-%m-%d') as targetDate + FROM fpsmsdb.pick_order po + LEFT JOIN fpsmsdb.delivery_order do_tbl ON do_tbl.id = po.doId + WHERE po.deliveryOrderPickOrderId = :dopoId + AND po.type = 'do' + AND po.deleted = 0 + ORDER BY po.id + """.trimIndent() + + val pickOrdersInfo = jdbcDao.queryForList(pickOrdersSql, mapOf("dopoId" to deliveryOrderPickOrderId)) + if (pickOrdersInfo.isEmpty()) { + return mapOf("fgInfo" to null, "pickOrders" to emptyList()) + } + + // Completed-record detail should keep each PO as an independent group + // (ticket can link multiple POs, e.g. 20 + 25), not merged into a single 45-line bucket. + val perPoPayloads = pickOrdersInfo.map { poInfo -> + assembleHierarchicalFgPayload( + jdbcDao = jdbcDao, + doPickOrderId = deliveryOrderPickOrderId, + doPickOrderInfo = doPickOrderInfo, + pickOrdersInfo = listOf(poInfo), + availableQtyInOutOnly = true, + ) + } + + val fgInfo = perPoPayloads.firstOrNull()?.get("fgInfo") + val pickOrders = perPoPayloads.flatMap { payload -> + (payload["pickOrders"] as? List<*>)?.filterIsInstance>() ?: emptyList() + } + return mapOf( + "fgInfo" to fgInfo, + "pickOrders" to pickOrders, + ) + } + + + /** DO workbench FG list from [delivery_order_pick_order] + linked [pick_order] (no do_pick_order_line). */ + open fun getFgPickOrdersByUserIdWorkbench(userId: Long): List> { + try { + println(" Starting getFgPickOrdersByUserIdWorkbench with userId: $userId") + + val sql = """ + SELECT + dop.id as doPickOrderId, + dop.storeId as storeId, + dop.ticketNo as ticketNo, + dop.truckLanceCode as truckLanceCode, + dop.truckDepartureTime as DepartureTime, + dop.shopCode as shopCode, + dop.shopName as shopName, + s.id as shopId, + s.name as shopNameFromShop, + CONCAT_WS(', ', s.addr1, s.addr2, s.addr3, s.addr4, s.district) as shopAddress, + GROUP_CONCAT(DISTINCT po.id ORDER BY po.id) as pickOrderIds, + GROUP_CONCAT(DISTINCT po.code ORDER BY po.id SEPARATOR ', ') as pickOrderCodes, + GROUP_CONCAT(DISTINCT do_tbl.id ORDER BY do_tbl.id) as deliveryOrderIds, + GROUP_CONCAT(DISTINCT do_tbl.code ORDER BY do_tbl.id SEPARATOR ', ') as deliveryNos, + (SELECT po2.consoCode FROM fpsmsdb.pick_order po2 WHERE po2.id = MIN(po.id) LIMIT 1) as pickOrderConsoCode, + (SELECT po2.targetDate FROM fpsmsdb.pick_order po2 WHERE po2.id = MIN(po.id) LIMIT 1) as pickOrderTargetDate, + (SELECT po2.status FROM fpsmsdb.pick_order po2 WHERE po2.id = MIN(po.id) LIMIT 1) as pickOrderStatus, + (SELECT do2.orderDate FROM fpsmsdb.delivery_order do2 WHERE do2.id = MIN(do_tbl.id) LIMIT 1) as deliveryDate, + COUNT(DISTINCT po.id) as numberOfPickOrders, + (SELECT SUM(pol_count.line_count) + FROM ( + SELECT po3.id, COUNT(*) as line_count + FROM fpsmsdb.pick_order po3 + JOIN fpsmsdb.pick_order_line pol3 ON pol3.poId = po3.id AND pol3.deleted = false + WHERE po3.deliveryOrderPickOrderId = dop.id AND po3.deleted = 0 + GROUP BY po3.id + ) pol_count + ) as numberOfCartons + FROM fpsmsdb.delivery_order_pick_order dop + INNER JOIN fpsmsdb.pick_order po ON po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + LEFT JOIN fpsmsdb.delivery_order do_tbl ON do_tbl.id = po.doId + LEFT JOIN fpsmsdb.shop s ON s.id = dop.shopId + WHERE po.assignTo = :userId + AND po.type = 'do' + AND po.status IN ('assigned', 'released', 'picking') + AND dop.handledBy = :userId + AND dop.ticketStatus = 'released' + AND dop.deleted = 0 + GROUP BY dop.id, dop.storeId, dop.ticketNo, dop.truckLanceCode, dop.truckDepartureTime, + dop.shopCode, dop.shopName, s.id, s.name, s.addr1, s.addr2, s.addr3, s.addr4, s.district + ORDER BY MIN(po.targetDate) DESC, MIN(po.code) ASC + """.trimIndent() + + // println(" Executing SQL for FG pick orders (workbench) by userId: $sql") + val results = jdbcDao.queryForList(sql, mapOf("userId" to userId)) + + if (results.isEmpty()) { + println("❌ No active FG workbench pick orders for user: $userId") + return emptyList() + } + + println(" Found ${results.size} active FG workbench pick orders for user: $userId") + + return results.map { row -> + val pickOrderIdsStr = row["pickOrderIds"] as? String ?: "" + val pickOrderIds = if (pickOrderIdsStr.isNotEmpty()) { + pickOrderIdsStr.split(",").mapNotNull { it.toLongOrNull() } + } else { + emptyList() + } + + mapOf( + "doPickOrderId" to (row["doPickOrderId"] ?: 0L), + "pickOrderIds" to pickOrderIds, + "pickOrderId" to (pickOrderIds.firstOrNull() ?: 0L), + "pickOrderCodes" to (row["pickOrderCodes"] ?: ""), + "pickOrderCode" to ((row["pickOrderCodes"] as? String)?.split(", ")?.firstOrNull() ?: ""), + "deliveryOrderIds" to (row["deliveryOrderIds"] as? String ?: "").split(",") + .mapNotNull { it.toLongOrNull() }, + "deliveryNos" to (row["deliveryNos"] ?: ""), + "pickOrderConsoCode" to (row["pickOrderConsoCode"] ?: ""), + "pickOrderTargetDate" to (row["pickOrderTargetDate"]?.toString() ?: ""), + "pickOrderStatus" to (row["pickOrderStatus"] ?: ""), + "deliveryDate" to (row["deliveryDate"]?.toString() ?: ""), + "shopId" to (row["shopId"] ?: 0L), + "shopCode" to (row["shopCode"] ?: ""), + "shopName" to (row["shopName"] ?: row["shopNameFromShop"] ?: ""), + "shopAddress" to (row["shopAddress"] ?: ""), + "shopPoNo" to "", + "numberOfPickOrders" to (row["numberOfPickOrders"] ?: 0), + "numberOfCartons" to (row["numberOfCartons"] ?: 0), + "truckLanceCode" to (row["truckLanceCode"] ?: ""), + "DepartureTime" to (row["DepartureTime"]?.toString() ?: ""), + "ticketNo" to (row["ticketNo"] ?: ""), + "storeId" to (row["storeId"] ?: ""), + "qrCodeData" to (row["doPickOrderId"] ?: 0L), + ) + } + } catch (e: Exception) { + println("❌ Error in getFgPickOrdersByUserIdWorkbench: ${e.message}") + e.printStackTrace() + return emptyList() + } + } + + /** + * After a stock-out line is in an end state (including qty=0 zero-complete), refresh pick_order_line / + * pick_order and, when all linked pick_orders are completed, mark [delivery_order_pick_order] completed. + * Mirrors the POL/PO/ticket tail of [postWorkbenchPickSideEffects] without ledger/bag (no inventory delta). + */ + private fun refreshWorkbenchPickOrderProgressAfterSolChange(stockOutLineId: Long) { + val saved = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return + val pol = saved.pickOrderLine ?: return + val polId = pol.id ?: return + val solsForPol = stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) + if (isWorkbenchSolEndStatus(saved.status)) { + tryCompletePickOrderLineWorkbench(polId, solsForPol) + } + checkWorkbenchPickOrderLineCompleted(polId, solsForPol) + val pickOrder = pol.pickOrder ?: return + val poId = pickOrder.id ?: return + val allLines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(poId) + val allCompleted = allLines.isNotEmpty() && allLines.all { it.status == PickOrderLineStatus.COMPLETED } + if (allCompleted) { + completePickOrderWithRetry(poId) + tryCompleteDeliveryOrderPickOrderTicketCompleted(poId) + } + } + + private fun postWorkbenchPickSideEffects(savedStockOutLine: StockOutLine, deltaQty: BigDecimal) { + if (deltaQty <= BigDecimal.ZERO) return + val wall0 = System.nanoTime() + var ledgerMs = 0L + var bagMs = 0L + var polCompleteMs = 0L + var polCheckMs = 0L + var poLinesFetchMs = 0L + var poCompleteAndDoMs = 0L + + ledgerMs = measureTimeMillis { createWorkbenchPickLedger(savedStockOutLine, deltaQty) } + try { + val bagT0 = System.nanoTime() + val solItem = savedStockOutLine.item + val inventoryLotLine = savedStockOutLine.inventoryLotLine + val reqDeltaQty = deltaQty.toDouble() + val statusLower = savedStockOutLine.status?.trim()?.lowercase() ?: "" + val isCompletedOrPartiallyCompleted = + statusLower == "completed" || + statusLower == "partially_completed" || + statusLower == StockOutLineStatus.PARTIALLY_COMPLETE.status.lowercase() + if (solItem?.isBag == true && + inventoryLotLine != null && + isCompletedOrPartiallyCompleted && + reqDeltaQty > 0 + ) { + val bag = bagRepository.findByItemIdAndDeletedIsFalse(solItem.id!!) + if (bag != null) { + val ln = inventoryLotLine.inventoryLot?.lotNo + if (ln != null) { + bagService.createBagLotLinesByBagId( + CreateBagLotLineRequest( + bagId = bag.id!!, + lotId = inventoryLotLine.inventoryLot?.id ?: 0L, + itemId = solItem.id!!, + lotNo = ln, + stockQty = reqDeltaQty.toInt(), + date = LocalDate.now(), + time = LocalTime.now(), + stockOutLineId = savedStockOutLine.id, + ), + ) + } + } + } + bagMs = (System.nanoTime() - bagT0) / 1_000_000 + } catch (e: Exception) { + println("Workbench bag side effect: ${e.message}") + e.printStackTrace() + } + val pol = savedStockOutLine.pickOrderLine ?: return + val polId = pol.id ?: return + val solsForPol = stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) + if (isWorkbenchSolEndStatus(savedStockOutLine.status)) { + polCompleteMs = measureTimeMillis { tryCompletePickOrderLineWorkbench(polId, solsForPol) } + } + polCheckMs = measureTimeMillis { checkWorkbenchPickOrderLineCompleted(polId, solsForPol) } + val pickOrder = pol.pickOrder + if (pickOrder != null && pickOrder.id != null) { + val poId = pickOrder.id!! + val t0 = System.nanoTime() + val allLines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(poId) + poLinesFetchMs = (System.nanoTime() - t0) / 1_000_000 + // Split rows use pick_order_line.status = partially_completed until fully picked; do not complete header PO until every line is completed. + val allCompleted = allLines.all { it.status == PickOrderLineStatus.COMPLETED } + if (allCompleted && allLines.isNotEmpty()) { + poCompleteAndDoMs = measureTimeMillis { + // Use reload+retry to avoid optimistic lock when other flows update the same PO. + completePickOrderWithRetry(poId) + // Workbench should only update delivery_order_pick_order.ticketStatus; do not touch do_pick_order* tables/records. + tryCompleteDeliveryOrderPickOrderTicketCompleted(poId) + } + } + } + val totalMs = (System.nanoTime() - wall0) / 1_000_000 + log.info( + "WORKBENCH_POST_TRACE solId={} polId={} deltaQty={} totalMs={} ledgerMs={} bagMs={} polCompleteMs={} polCheckMs={} poLinesFetchMs={} poCompleteAndDoMs={}", + savedStockOutLine.id ?: 0L, + polId, + deltaQty, + totalMs, + ledgerMs, + bagMs, + polCompleteMs, + polCheckMs, + poLinesFetchMs, + poCompleteAndDoMs, + ) + } + + private fun createWorkbenchPickLedger(sol: StockOutLine, deltaQty: BigDecimal) { + if (deltaQty <= BigDecimal.ZERO) return + val solItem = sol.item ?: return + val inventory = itemUomService.findInventoryForItemBaseUom(solItem.id!!) ?: return + // Fast path (batch-submit style): avoid querying latest ledger. + // At this point inventory.onHandQty has already been updated by DB triggers after lot outQty change. + // So previousBalance can be reconstructed as (onHandAfter + deltaQty). + val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() + val previousBalance = onHandAfter + deltaQty.toDouble() + val newBalance = previousBalance - deltaQty.toDouble() + val ledger = StockLedger().apply { + this.stockOutLine = sol + this.inventory = inventory + this.inQty = null + this.outQty = deltaQty.toDouble() + this.balance = newBalance + this.type = "NOR" + this.itemId = solItem.id + this.itemCode = solItem.code + this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id + ?: inventory.uom?.id + this.date = LocalDate.now() + } + // Do not flush per pick; transaction flush is sufficient and avoids slow roundtrips/locks. + stockLedgerRepository.save(ledger) + } + + private fun resolveWorkbenchPreviousBalance( + itemId: Long, + inventory: Inventory, + onHandQtyBeforeUpdate: Double?, + ): Double { + if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate + val latestLedger = stockLedgerRepository.findLatestByItemId(itemId).firstOrNull() + return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() + } + + private fun isWorkbenchSolEndStatus(status: String?): Boolean { + val s = status?.trim()?.lowercase() ?: return false + return s == "completed" || s == "rejected" || s == "partially_completed" || + s == StockOutLineStatus.COMPLETE.status.lowercase() || + s == StockOutLineStatus.PARTIALLY_COMPLETE.status.lowercase() + } + + /** + * When every SOL on the POL is in a workbench "ended" state, mark POL completed and run conso completion. + * Skips [checkAndCompletePickOrderByConsoCode] if POL was already completed (avoids full conso scan every pick). + */ + private fun tryCompletePickOrderLineWorkbench(pickOrderLineId: Long, sols: List) { + val polEntity = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return + if (polEntity.status == PickOrderLineStatus.COMPLETED) return + if (sols.isEmpty()) return + val allEnded = sols.all { isWorkbenchSolEndStatus(it.status) } + if (!allEnded) return + polEntity.status = PickOrderLineStatus.COMPLETED + pickOrderLineRepository.save(polEntity) + } + + /** + * Complete PO header with optimistic-lock retry. + * Workbench should have a single source of truth for PO completion (avoid conso completion overlap). + */ + private fun completePickOrderWithRetry(poId: Long, maxAttempts: Int = 3) { + var last: Exception? = null + repeat(maxAttempts) { + try { + entityManager.clear() + val po = pickOrderRepository.findById(poId).orElse(null) ?: return + if (po.status == PickOrderStatus.COMPLETED) return + po.status = PickOrderStatus.COMPLETED + po.completeDate = LocalDateTime.now() + pickOrderRepository.saveAndFlush(po) + return + } catch (e: ObjectOptimisticLockingFailureException) { + last = e + Thread.sleep((10L..50L).random()) + } + } + throw last ?: RuntimeException("Failed to complete pick order after retries (poId=$poId)") + } + + /** + * Workbench completion: if all pick_orders under the same delivery_order_pick_order are completed, + * update ONLY delivery_order_pick_order.ticketStatus (no do_pick_order/do_pick_order_line records). + */ + private fun tryCompleteDeliveryOrderPickOrderTicketCompleted(poId: Long) { + val dopRow = jdbcDao.queryForMap( + "SELECT deliveryOrderPickOrderId AS dopId FROM fpsmsdb.pick_order WHERE id = :poId AND deleted = 0", + mapOf("poId" to poId), + ).orElse(null) ?: return + val dopId = (dopRow["dopId"] as? Number)?.toLong() ?: return + if (dopId <= 0L) return + + val unfinished = jdbcDao.queryForInt( + """ + SELECT COUNT(1) + FROM fpsmsdb.pick_order po + WHERE po.deliveryOrderPickOrderId = :dopId + AND po.deleted = 0 + AND po.type = 'do' + AND po.status <> 'completed' + """.trimIndent(), + mapOf("dopId" to dopId), + ) + if (unfinished > 0) return + + val prefix = "DN" + val midfix = CodeGenerator.DEFAULT_MIDFIX + val codePrefix = "$prefix-$midfix" + val latestLegacyDn = doPickOrderRecordRepository.findLatestDeliveryNoteCodeByPrefix(codePrefix) + val latestWorkbenchDn = deliveryOrderPickOrderRepository.findLatestDeliveryNoteCodeByPrefix(codePrefix) + val latestCode = listOfNotNull(latestLegacyDn, latestWorkbenchDn).maxOrNull() + val newDeliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode) + + jdbcDao.executeUpdate( + """ + UPDATE fpsmsdb.delivery_order_pick_order dop + SET dop.ticketStatus = 'completed', + dop.ticketCompleteDateTime = NOW(), + dop.deliveryNoteCode = CASE + WHEN dop.deliveryNoteCode IS NULL OR TRIM(dop.deliveryNoteCode) = '' THEN :deliveryNoteCode + ELSE dop.deliveryNoteCode + END + WHERE dop.id = :dopId + AND dop.deleted = 0 + """.trimIndent(), + mapOf("dopId" to dopId, "deliveryNoteCode" to newDeliveryNoteCode), + ) + } + + private fun checkWorkbenchPickOrderLineCompleted(pickOrderLineId: Long, allStockOutLines: List) { + val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return + if (pol.status == PickOrderLineStatus.COMPLETED) return + val unfinishedLine = allStockOutLines.filter { + val rawStatus = it.status.trim() + val status = rawStatus.lowercase() + val isComplete = + status == "completed" || status == StockOutLineStatus.COMPLETE.status.lowercase() + val isRejected = + status == "rejected" || status == StockOutLineStatus.REJECTED.status.lowercase() + val isPartiallyComplete = + status == "partially_completed" || status == StockOutLineStatus.PARTIALLY_COMPLETE.status.lowercase() + !(isComplete || isRejected || isPartiallyComplete) + } + if (unfinishedLine.isEmpty()) { + pickOrderLineRepository.save( + pol.apply { this.status = PickOrderLineStatus.COMPLETED }, + ) + } + } + + private fun completeDoForPickOrderWorkbench(pickOrderId: Long) { + val dpos = doPickOrderRepository.findByPickOrderId(pickOrderId) + if (dpos.isNotEmpty()) { + dpos.forEach { + it.ticketStatus = DoPickOrderStatus.completed + it.ticketCompleteDateTime = LocalDateTime.now() + } + doPickOrderRepository.saveAll(dpos) + dpos.forEach { dpo -> + dpo.doOrderId?.let { doId -> + val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId) + if (deliveryOrder != null && deliveryOrder.status != DeliveryOrderStatus.COMPLETED) { + deliveryOrder.status = DeliveryOrderStatus.COMPLETED + deliveryOrderRepository.save(deliveryOrder) + } + } + } + } + val dporList = doPickOrderRecordRepository.findByPickOrderId(pickOrderId) + if (dporList.isNotEmpty()) { + dporList.forEach { + it.ticketStatus = DoPickOrderStatus.completed + it.ticketCompleteDateTime = LocalDateTime.now() + } + doPickOrderRecordRepository.saveAll(dporList) + } + } + + private fun completeDoIfAllPickOrdersCompletedWorkbench(pickOrderId: Long) { + val lines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(pickOrderId) + val doPickOrderIds = + if (lines.isNotEmpty()) lines.mapNotNull { it.doPickOrderId }.distinct() + else doPickOrderRepository.findByPickOrderId(pickOrderId).mapNotNull { it.id } + + doPickOrderIds.forEach { dpoId -> + val allLines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(dpoId) + val allPickOrderIdsInDpo = if (allLines.isNotEmpty()) { + allLines.mapNotNull { it.pickOrderId }.distinct() + } else { + doPickOrderRepository.findById(dpoId).orElse(null)?.pickOrderId?.let { listOf(it) } ?: emptyList() + } + if (allPickOrderIdsInDpo.isEmpty()) return@forEach + + val statuses = allPickOrderIdsInDpo.map { id -> + pickOrderRepository.findById(id).orElse(null)?.status + } + val allCompleted = statuses.all { it == PickOrderStatus.COMPLETED } + if (!allCompleted) return@forEach + + val dpo = doPickOrderRepository.findById(dpoId).orElse(null) ?: return@forEach + dpo.ticketStatus = DoPickOrderStatus.completed + dpo.ticketCompleteDateTime = LocalDateTime.now() + doPickOrderRepository.save(dpo) + + val prefix = "DN" + val midfix = CodeGenerator.DEFAULT_MIDFIX + val latestCode = doPickOrderRecordRepository.findLatestDeliveryNoteCodeByPrefix("${prefix}-${midfix}") + val deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode) + + val dpoRecord = DoPickOrderRecord( + recordId = dpoId, + storeId = dpo.storeId ?: "", + ticketNo = dpo.ticketNo ?: "", + ticketStatus = DoPickOrderStatus.completed, + truckId = dpo.truckId, + pickOrderId = dpo.pickOrderId, + truckDepartureTime = dpo.truckDepartureTime, + shopId = dpo.shopId, + handledBy = dpo.handledBy, + handlerName = dpo.handlerName, + doOrderId = dpo.doOrderId, + pickOrderCode = dpo.pickOrderCode, + deliveryOrderCode = dpo.deliveryOrderCode, + deliveryNoteCode = deliveryNoteCode, + loadingSequence = dpo.loadingSequence, + ticketReleaseTime = dpo.ticketReleaseTime, + ticketCompleteDateTime = LocalDateTime.now(), + truckLanceCode = dpo.truckLanceCode, + shopCode = dpo.shopCode, + shopName = dpo.shopName, + requiredDeliveryDate = dpo.requiredDeliveryDate, + ) + val savedHeader = doPickOrderRecordRepository.save(dpoRecord) + + val lineRecords = allLines.map { l -> + DoPickOrderLineRecord().apply { + this.recordId = l.id + this.doPickOrderId = savedHeader.recordId + this.pickOrderId = l.pickOrderId + this.doOrderId = l.doOrderId + this.pickOrderCode = l.pickOrderCode + this.deliveryOrderCode = l.deliveryOrderCode + this.status = l.status + } + } + if (lineRecords.isNotEmpty()) doPickOrderLineRecordRepository.saveAll(lineRecords) + if (allLines.isNotEmpty()) doPickOrderLineRepository.deleteAll(allLines) + doPickOrderRepository.delete(dpo) + + dpo.doOrderId?.let { doId -> + val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId) + if (deliveryOrder != null && deliveryOrder.status != DeliveryOrderStatus.COMPLETED) { + deliveryOrder.status = DeliveryOrderStatus.COMPLETED + deliveryOrderRepository.save(deliveryOrder) + } + } + } + } + @Transactional + open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) { + val printer = + printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") + + val pdf = exportDeliveryNoteWorkbench( + ExportDeliveryNoteRequest( + doPickOrderId = request.doPickOrderId, + numOfCarton = request.numOfCarton, + isDraft = request.isDraft, + ), + ) + + val jasperPrint = pdf["report"] as JasperPrint + val tempPdfFile = File.createTempFile("print_job_", ".pdf") + try { + JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) + val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty + printer.ip?.let { ip -> + val port = printer.port ?: 9100 + val driver = A4PrintDriverRegistry.getDriver(printer.brand) + driver.print(tempPdfFile, ip, port, printQty) + } + updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) + } finally { + //tempPdfFile.delete() + } + } + + private data class WorkbenchPrintContext( + val header: DeliveryOrderPickOrder, + val pickOrderIds: List, + val pickOrderCodes: List, + val deliveryOrderIds: List, + ) + + private fun getWorkbenchPrintContext(deliveryOrderPickOrderId: Long): WorkbenchPrintContext { + val header = deliveryOrderPickOrderRepository.findById(deliveryOrderPickOrderId).orElseThrow { + NoSuchElementException("DeliveryOrderPickOrder not found with ID: $deliveryOrderPickOrderId") + } + + val rows = jdbcDao.queryForList( + """ + SELECT po.id AS id, po.code AS code, po.doId AS doId + FROM fpsmsdb.pick_order po + WHERE po.deliveryOrderPickOrderId = :dopoId + AND po.deleted = 0 + """.trimIndent(), + mapOf("dopoId" to deliveryOrderPickOrderId), + ) + if (rows.isEmpty()) { + throw IllegalStateException("DeliveryOrderPickOrder $deliveryOrderPickOrderId has no associated pick orders") + } + + val pickOrderIds = rows.mapNotNull { (it["id"] as? Number)?.toLong() }.distinct() + val pickOrderCodes = rows.mapNotNull { it["code"]?.toString()?.takeIf { s -> s.isNotBlank() } }.distinct() + val deliveryOrderIds = rows.mapNotNull { (it["doId"] as? Number)?.toLong() }.distinct() + if (deliveryOrderIds.isEmpty()) { + throw IllegalStateException("DeliveryOrderPickOrder $deliveryOrderPickOrderId has no associated delivery orders") + } + + return WorkbenchPrintContext( + header = header, + pickOrderIds = pickOrderIds, + pickOrderCodes = pickOrderCodes, + deliveryOrderIds = deliveryOrderIds, + ) + } + + private fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map { + val DELIVERYNOTE_PDF = "DeliveryNote/DeliveryNotePDF.jrxml" + val resource = ClassPathResource(DELIVERYNOTE_PDF) + if (!resource.exists()) { + throw FileNotFoundException("Report file not found: $DELIVERYNOTE_PDF") + } + + val inputStream = resource.inputStream + val deliveryNote = JasperCompileManager.compileReport(inputStream) + val fields = mutableListOf>() + val params = mutableMapOf() + + val ctx = getWorkbenchPrintContext(request.doPickOrderId) + val deliveryNoteInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> + deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) + }.toMutableList() + if (deliveryNoteInfo.isEmpty()) { + throw NoSuchElementException("Delivery orders not found for IDs: ${ctx.deliveryOrderIds}") + } + + val allLines = deliveryNoteInfo.flatMap { info -> info.deliveryOrderLines.map { it } } + val pickOrderLines = ctx.pickOrderIds.flatMap { pid -> + pickOrderLineRepository.findAllByPickOrderId(pid) + } + val pickOrderLineIdsByItemId = pickOrderLines + .groupBy { it.item?.id } + .mapValues { (_, lines) -> lines.mapNotNull { it.id } } + val allPickOrderLineIds = pickOrderLines.mapNotNull { it.id } + val stockOutLinesByPickOrderLineId: Map> = + if (allPickOrderLineIds.isNotEmpty()) { + allPickOrderLineIds.associateWith { polId -> + stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) + } + } else { + emptyMap() + } + + val doStoreFloorKey = ctx.header.storeId?.toString()?.trim()?.uppercase(Locale.ROOT) + ?.replace("/", "") + ?.replace(" ", "") + ?: "" + val uniqueItemIdsForSort = allLines.mapNotNull { it.itemId }.distinct() + val itemsById = if (uniqueItemIdsForSort.isNotEmpty()) { + itemsRepository.findAllById(uniqueItemIdsForSort).associateBy { it.id!! } + } else { + emptyMap() + } + val sortedLines = when (doStoreFloorKey) { + "2F" -> allLines.sortedWith( + compareBy( + { line -> itemsById[line.itemId]?.item_Order ?: Int.MAX_VALUE }, + { line -> line.itemNo }, + ), + ) + else -> allLines.sortedBy { line -> + line.itemId?.let { deliveryOrderService.getWarehouseOrderByItemId(it) } ?: Int.MAX_VALUE + } + } + val allIllIds = stockOutLinesByPickOrderLineId.values + .flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } } + .distinct() + val illById: Map = if (allIllIds.isNotEmpty()) { + inventoryLotLineRepository.findAllById(allIllIds).associateBy { it.id!! } + } else { + emptyMap() + } + + sortedLines.forEach { line -> + val field = mutableMapOf() + field["sequenceNumber"] = (fields.size + 1).toString() + field["itemNo"] = line.itemNo + field["itemName"] = line.itemName ?: "" + field["uom"] = line.uom ?: "" + val actualPickQty = if (line.itemId != null) { + val pickOrderLineIdsForItem = pickOrderLineIdsByItemId[line.itemId] ?: emptyList() + val totalQty = pickOrderLineIdsForItem.sumOf { polId -> + stockOutLinesByPickOrderLineId[polId].orEmpty().sumOf { it.qty } + } + totalQty.toString() + } else { + line.qty.toString() + } + field["qty"] = actualPickQty + field["shortName"] = line.uomShortDesc ?: "" + val route = line.itemId?.let { itemId -> + deliveryOrderService.routeFromStockOutsForItem( + itemId, + pickOrderLineIdsByItemId, + stockOutLinesByPickOrderLineId, + illById, + ).takeIf { it != "-" } ?: deliveryOrderService.getWarehouseCodeByItemId(itemId) + } ?: "-" + field["route"] = route + val lotNo = line.itemId?.let { itemId -> + val pickOrderLineIdsForItem = pickOrderLineIdsByItemId[itemId] ?: emptyList() + val lotNumbers = pickOrderLineIdsForItem.flatMap { polId -> + stockOutLinesByPickOrderLineId[polId].orEmpty().mapNotNull { it.lotNo } + }.distinct().joinToString(", ") + lotNumbers.ifBlank { "沒有庫存" } + } ?: "沒有庫存" + field["lotNo"] = lotNo + val signOff = line.itemId?.let { itemId -> + if (itemsById[itemId]?.isEgg == true) "簽署: __________" else "" + } ?: "" + field["signOff"] = signOff + fields.add(field) + } + + params["dnTitle"] = "送貨單" + params["colQty"] = "已提數量" + params["totalCartonTitle"] = "總箱數:" + params["deliveryNoteCodeTitle"] = "送貨單編號:" + params["deliveryNoteCode"] = ctx.header.deliveryNoteCode ?: "" + params["numOfCarton"] = request.numOfCarton.toString().takeUnless { it == "0" } ?: "" + params["shopName"] = ctx.header.shopName ?: deliveryNoteInfo[0].shopName ?: "" + params["shopAddress"] = deliveryNoteInfo[0].shopAddress ?: "" + params["deliveryDate"] = ctx.header.requiredDeliveryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) + ?: deliveryNoteInfo[0].estimatedArrivalDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) + ?: "" + params["truckNo"] = ctx.header.truckLanceCode ?: "" + params["ShopPurchaseOrderNo"] = deliveryNoteInfo.joinToString(", ") { it.code } + params["FGPickOrderNo"] = ctx.pickOrderCodes.joinToString(", ") + params["loadingSequence"] = "" + + return mapOf( + "report" to PdfUtils.fillReport(deliveryNote, fields, params), + "filename" to deliveryNoteInfo.joinToString("_") { it.code }, + ) + } + @Transactional + open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) { + val printer = + printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") + + val pdf = exportDNLabelsWorkbench( + ExportDNLabelsRequest( + doPickOrderId = request.doPickOrderId, + numOfCarton = request.numOfCarton, + ), + ) + + val jasperPrint = pdf["report"] as JasperPrint + val tempPdfFile = File.createTempFile("print_job_", ".pdf") + try { + JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) + val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty + printer.ip?.let { ip -> + printer.port?.let { port -> + ZebraPrinterUtil.printPdfToZebra( + tempPdfFile, + ip, + port, + printQty, + ZebraPrinterUtil.PrintDirection.ROTATED, + printer.dpi, + ) + } + } + updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) + } finally { + //tempPdfFile.delete() + } + } + + private fun exportDNLabelsWorkbench(request: ExportDNLabelsRequest): Map { + val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" + val resource = ClassPathResource(DNLABELS_PDF) + if (!resource.exists()) { + throw FileNotFoundException("Label file not found: $DNLABELS_PDF") + } + + val ctx = getWorkbenchPrintContext(request.doPickOrderId) + val inputStream = resource.inputStream + val cartonLabel = JasperCompileManager.compileReport(inputStream) + val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> + deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) + }.toMutableList() + if (cartonLabelInfo.isEmpty()) { + throw NoSuchElementException("Delivery orders not found for IDs: ${ctx.deliveryOrderIds}") + } + + val params = mutableMapOf() + val fields = mutableListOf>() + + params["shopPurchaseOrderNo"] = if (ctx.deliveryOrderIds.size > 1) { + "請查閲送貨單(採購單共${ctx.deliveryOrderIds.size}張)" + } else { + cartonLabelInfo[0].code + } + params["deliveryNoteCode"] = ctx.header.deliveryNoteCode ?: "" + params["shopAddress"] = cartonLabelInfo[0].shopAddress ?: "" + val rawShopLabel = ctx.header.shopName ?: cartonLabelInfo[0].shopName ?: "" + val parsedShopLabel = deliveryOrderService.parseShopLabelForCartonLabel(rawShopLabel) + params["shopCode"] = parsedShopLabel.shopCode + params["shopCodeAbbr"] = parsedShopLabel.shopCodeAbbr + params["shopName"] = parsedShopLabel.shopNameForLabel + params["truckNo"] = ctx.header.truckLanceCode ?: "" + + for (cartonNumber in 1..request.numOfCarton) { + fields.add( + mutableMapOf( + "cartonIndex" to cartonNumber, + "cartonTotal" to request.numOfCarton, + ) + ) + } + + return mapOf( + "report" to PdfUtils.fillReport(cartonLabel, fields, params), + "filename" to "${cartonLabelInfo[0].code}_carton_labels", + ) + } + + private fun updateWorkbenchCartonQty(deliveryOrderPickOrderId: Long, cartonQty: Int) { + if (cartonQty <= 0) return + val workbenchHeader = deliveryOrderPickOrderRepository.findById(deliveryOrderPickOrderId).orElse(null) ?: return + workbenchHeader.cartonQty = cartonQty + deliveryOrderPickOrderRepository.save(workbenchHeader) + } + +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt new file mode 100644 index 0000000..59f1bcd --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt @@ -0,0 +1,479 @@ +package com.ffii.fpsms.modules.deliveryOrder.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus +import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleaseDoRequest +import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleaseDoResult +import com.ffii.fpsms.modules.master.web.models.MessageResponse +import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService +import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.sql.SQLException +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.Semaphore +import java.util.concurrent.atomic.AtomicInteger +import jakarta.persistence.OptimisticLockException +import org.hibernate.StaleObjectStateException +import org.springframework.orm.ObjectOptimisticLockingFailureException + +private const val WORKBENCH_RELEASE_RETRY_MAX = 3 + +private fun isWorkbenchOptimisticLockFailure(t: Throwable?): Boolean { + var c: Throwable? = t + while (c != null) { + when (c) { + is StaleObjectStateException -> return true + is OptimisticLockException -> return true + is ObjectOptimisticLockingFailureException -> return true + } + if (c.message?.contains("Row was updated or deleted by another transaction", ignoreCase = true) == true) { + return true + } + c = c.cause + } + return false +} + +private fun isWorkbenchDeadlockFailure(t: Throwable?): Boolean { + var c: Throwable? = t + while (c != null) { + if (c.message?.contains("Deadlock", ignoreCase = true) == true) return true + if (c.message?.contains("try restarting transaction", ignoreCase = true) == true) return true + (c as? SQLException)?.let { sql -> + if (sql.sqlState == "40001") return true + if (sql.errorCode == 1213) return true + } + c = c.cause + } + return false +} + +private fun isWorkbenchRetriableFailure(t: Throwable?): Boolean = + isWorkbenchOptimisticLockFailure(t) || isWorkbenchDeadlockFailure(t) + +data class WorkbenchBatchReleaseSummary( + val total: Int, + val success: Int, + val failed: List>, + val createdDeliveryOrderPickOrders: Int, +) + +data class WorkbenchBatchReleaseJobStatus( + val jobId: String, + val total: Int, + val success: AtomicInteger = AtomicInteger(0), + val failed: MutableList> = mutableListOf(), + @Volatile var running: Boolean = true, + val startedAt: Long = Instant.now().toEpochMilli(), + @Volatile var finishedAt: Long? = null, +) + +@Service +open class DoWorkbenchReleaseService( + private val deliveryOrderService: DeliveryOrderService, + private val jdbcDao: JdbcDao, + private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, + private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, +) { + private val poolSize = Runtime.getRuntime().availableProcessors() + private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4)) + /** Only one workbench batch-release job runs at a time (per JVM). */ + private val batchReleaseConcurrencyGate = Semaphore(1, true) + private val jobs = ConcurrentHashMap() + + private fun releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId: Long, userId: Long, useV2: Boolean): ReleaseDoResult? { + var released: ReleaseDoResult? = null + for (attempt in 1..WORKBENCH_RELEASE_RETRY_MAX) { + try { + released = if (useV2) { + deliveryOrderService.releaseDeliveryOrderWithoutTicketNoHoldV2( + ReleaseDoRequest(id = deliveryOrderId, userId = userId) + ) + } else { + deliveryOrderService.releaseDeliveryOrderWithoutTicketNoHold( + ReleaseDoRequest(id = deliveryOrderId, userId = userId) + ) + } + break + } catch (e: Exception) { + if (isWorkbenchRetriableFailure(e) && attempt < WORKBENCH_RELEASE_RETRY_MAX) { + Thread.sleep(50L * attempt) + } else { + throw e + } + } + } + return released + } + + open fun startBatchReleaseAsync(ids: List, userId: Long): MessageResponse = + startBatchReleaseAsyncInternal(ids, userId, useV2 = false) + + /** + * V2: deferred suggested pick / stock out / stock out lines until [DoWorkbenchDopoAssignmentService] assigns the ticket. + */ + open fun startBatchReleaseAsyncV2(ids: List, userId: Long): MessageResponse = + startBatchReleaseAsyncInternal(ids, userId, useV2 = true) + + private fun startBatchReleaseAsyncInternal(ids: List, userId: Long, useV2: Boolean): MessageResponse { + if (ids.isEmpty()) { + return MessageResponse( + id = null, + code = "NO_IDS", + name = null, + type = if (useV2) "workbench_batch_release_async_v2" else "workbench_batch_release_async", + message = "No delivery order ids provided", + errorPosition = null, + entity = null + ) + } + + val jobId = UUID.randomUUID().toString() + val status = WorkbenchBatchReleaseJobStatus(jobId = jobId, total = ids.size) + jobs[jobId] = status + + executor.submit { + batchReleaseConcurrencyGate.acquireUninterruptibly() + try { + val orderedIds = getOrderedDeliveryOrderIds(ids) + val successResults = mutableListOf() + + orderedIds.forEach { deliveryOrderId -> + try { + val statusRows = jdbcDao.queryForList( + """ + SELECT status + FROM fpsmsdb.delivery_order + WHERE id = :id AND deleted = 0 + """.trimIndent(), + mapOf("id" to deliveryOrderId) + ) + val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase() + if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) { + return@forEach + } + + val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2) + + if (released != null) { + successResults += released + status.success.incrementAndGet() + } else { + synchronized(status.failed) { + status.failed.add(deliveryOrderId to "Release returned null") + } + } + } catch (e: Exception) { + synchronized(status.failed) { + status.failed.add(deliveryOrderId to (e.message ?: "Unknown error")) + } + } + } + + try { + createAndLinkDeliveryOrderPickOrders(successResults) + } catch (e: Exception) { + // header-link failure shouldn't crash job; status.failed already includes per-DO failures + println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}") + } + + if (!useV2) { + successResults.forEach { result -> + try { + suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(result.pickOrderId) + stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(result.pickOrderId, userId) + } catch (e: Exception) { + synchronized(status.failed) { + status.failed.add(result.deliveryOrderId to ("Downstream workbench step failed: ${e.message}")) + } + } + } + } + } finally { + status.running = false + status.finishedAt = Instant.now().toEpochMilli() + batchReleaseConcurrencyGate.release() + } + } + + return MessageResponse( + id = null, + code = "STARTED", + name = null, + type = if (useV2) "workbench_batch_release_async_v2" else "workbench_batch_release_async", + message = if (useV2) "Workbench batch release V2 started" else "Workbench batch release started", + errorPosition = null, + entity = mapOf("jobId" to jobId, "total" to ids.size) + ) + } + + open fun getBatchReleaseProgress(jobId: String): MessageResponse { + val s = jobs[jobId] ?: return MessageResponse( + id = null, + code = "NOT_FOUND", + name = null, + type = "workbench_batch_release_progress", + message = "Job not found", + errorPosition = null, + entity = null + ) + val finished = s.success.get() + s.failed.size + val progress = (finished.toDouble() / s.total).coerceIn(0.0, 1.0) + + return MessageResponse( + id = null, + code = if (s.running) "RUNNING" else "FINISHED", + name = null, + type = "workbench_batch_release_progress", + message = null, + errorPosition = null, + entity = mapOf( + "jobId" to s.jobId, + "total" to s.total, + "finished" to finished, + "success" to s.success.get(), + "failedCount" to s.failed.size, + "failed" to s.failed.take(50), + "running" to s.running, + "progress" to progress, + "startedAt" to s.startedAt, + "finishedAt" to s.finishedAt + ) + ) + } + + /** + * New batch release flow for workbench: + * 1) release DO -> create pick_order (existing flow), + * 2) create grouped header in delivery_order_pick_order, + * 3) backfill pick_order.deliveryOrderPickOrderId. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun releaseBatch(ids: List, userId: Long): MessageResponse = + releaseBatchInternal(ids, userId, useV2 = false) + + /** + * Synchronous batch release V2: same as [releaseBatch] but uses [DeliveryOrderService.releaseDeliveryOrderWithoutTicketNoHoldV2] + * and skips suggested pick + stock out line creation (done on ticket assign). + */ + @Transactional(rollbackFor = [Exception::class]) + open fun releaseBatchV2(ids: List, userId: Long): MessageResponse = + releaseBatchInternal(ids, userId, useV2 = true) + + private fun releaseBatchInternal(ids: List, userId: Long, useV2: Boolean): MessageResponse { + if (ids.isEmpty()) { + return MessageResponse( + id = null, + code = "NO_IDS", + name = null, + type = if (useV2) "workbench_batch_release_v2" else "workbench_batch_release", + message = "No delivery order ids provided", + errorPosition = null, + entity = null + ) + } + + val orderedIds = getOrderedDeliveryOrderIds(ids) + val successResults = mutableListOf() + val failed = mutableListOf>() + + orderedIds.forEach { deliveryOrderId -> + try { + // Skip completed-like status explicitly to keep batch idempotent. + val statusRows = jdbcDao.queryForList( + """ + SELECT status + FROM fpsmsdb.delivery_order + WHERE id = :id AND deleted = 0 + """.trimIndent(), + mapOf("id" to deliveryOrderId) + ) + val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase() + if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) { + return@forEach + } + + val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2) + + if (released == null) { + failed += deliveryOrderId to "Release returned null" + } else { + successResults += released + } + } catch (e: Exception) { + failed += deliveryOrderId to (e.message ?: "Unknown error") + } + } + + val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults) + if (!useV2) { + successResults.forEach { result -> + try { + suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(result.pickOrderId) + stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(result.pickOrderId, userId) + } catch (_: Exception) { + // keep batch release resilient; failures are reflected by missing downstream data and can be re-triggered + } + } + } + val summary = WorkbenchBatchReleaseSummary( + total = ids.size, + success = successResults.size, + failed = failed, + createdDeliveryOrderPickOrders = createdHeaders + ) + + return MessageResponse( + id = null, + code = if (failed.isEmpty()) "SUCCESS" else "PARTIAL_SUCCESS", + name = if (useV2) "workbench_batch_release_v2" else "workbench_batch_release", + type = if (useV2) "workbench_batch_release_v2" else "workbench_batch_release", + message = "Released ${successResults.size}/${ids.size} delivery orders", + errorPosition = null, + entity = summary + ) + } + + /** + * Same visual format as batch DO pick tickets (`DoReleaseCoordinatorService`): `TI-B-yyyyMMdd-2F-001`. + * Allocates the next 3-digit suffix by scanning existing `do_pick_order.ticket_no` and + * `delivery_order_pick_order.ticketNo` with the same prefix (avoids `uk_dopo_ticket_no` clashes). + */ + private fun nextDeliveryOrderPickOrderBatchTicketNo(requiredDate: LocalDate, storeDisplay: String): String { + val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")) + val floor = storeDisplay.replace("/", "").trim() + val prefix = "TI-B-$ymd-$floor-" + val sql = """ + SELECT ticket_no AS t FROM fpsmsdb.do_pick_order WHERE deleted = 0 AND ticket_no LIKE CONCAT(:prefix, '%') + UNION ALL + SELECT ticketNo AS t FROM fpsmsdb.delivery_order_pick_order WHERE deleted = 0 AND ticketNo LIKE CONCAT(:prefix, '%') + """.trimIndent() + val rows = try { + jdbcDao.queryForList(sql, mapOf("prefix" to prefix)) + } catch (_: Exception) { + emptyList() + } + val suffixPattern = Regex("^${Regex.escape(prefix)}(\\d+)$") + var maxSeq = 0 + for (row in rows) { + val key = row.keys.find { it.equals("t", ignoreCase = true) } ?: row.keys.firstOrNull() ?: continue + val ticket = row[key]?.toString() ?: continue + suffixPattern.find(ticket)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let { if (it > maxSeq) maxSeq = it } + } + val next = maxSeq + 1 + return "$prefix${next.toString().padStart(3, '0')}" + } + + private fun getOrderedDeliveryOrderIds(ids: List): List { + if (ids.isEmpty()) return emptyList() + return try { + val sql = """ + SELECT do.id + FROM fpsmsdb.delivery_order do + WHERE do.deleted = 0 + AND do.id IN (${ids.joinToString(",")}) + ORDER BY DATE(do.estimatedArrivalDate), do.id + """.trimIndent() + val sorted = jdbcDao.queryForList(sql).mapNotNull { (it["id"] as? Number)?.toLong() } + if (sorted.isEmpty()) ids else sorted + } catch (_: Exception) { + ids + } + } + + private fun createAndLinkDeliveryOrderPickOrders(results: List): Int { + if (results.isEmpty()) return 0 + + val grouped = results.groupBy { + listOf( + it.shopId?.toString() ?: "", + it.estimatedArrivalDate?.toString() ?: "", + it.preferredFloor, + it.truckId?.toString() ?: "", + it.truckDepartureTime?.toString() ?: "", + it.truckLanceCode ?: "" + ).joinToString("|") + } + + var createdHeaders = 0 + grouped.values.forEach { group -> + val first = group.first() + val storeId = when (first.preferredFloor) { + "2F" -> "2/F" + "4F" -> "4/F" + else -> "2/F" + } + val requiredDate = first.estimatedArrivalDate ?: LocalDate.now() + val tempTicket = nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, storeId) + val now = LocalDateTime.now() + + // Column names must match Liquibase `01_alter_stock_take.sql` (camelCase), not snake_case. + jdbcDao.executeUpdate( + """ + INSERT INTO fpsmsdb.delivery_order_pick_order ( + truckId, shopId, storeId, requiredDeliveryDate, truckDepartureTime, + truckLanceCode, shopCode, shopName, loadingSequence, ticketNo, + ticketReleaseTime, ticketStatus, releaseType, handledBy, handlerName, + created, createdBy, version, modified, modifiedBy, deleted + ) VALUES ( + :truckId, :shopId, :storeId, :requiredDeliveryDate, :truckDepartureTime, + :truckLanceCode, :shopCode, :shopName, :loadingSequence, :ticketNo, + NULL, 'pending', 'batch', NULL, NULL, + :created, :createdBy, 0, :modified, :modifiedBy, 0 + ) + """.trimIndent(), + mapOf( + "truckId" to first.truckId, + "shopId" to first.shopId, + "storeId" to storeId, + "requiredDeliveryDate" to (first.estimatedArrivalDate ?: LocalDate.now()), + "truckDepartureTime" to first.truckDepartureTime, + "truckLanceCode" to first.truckLanceCode, + "shopCode" to first.shopCode, + "shopName" to first.shopName, + "loadingSequence" to first.loadingSequence, + "ticketNo" to tempTicket, + "created" to now, + "createdBy" to "system", + "modified" to now, + "modifiedBy" to "system", + ) + ) + + val headerId = jdbcDao.queryForList( + """ + SELECT id + FROM fpsmsdb.delivery_order_pick_order + WHERE ticketNo = :ticketNo + ORDER BY id DESC + LIMIT 1 + """.trimIndent(), + mapOf("ticketNo" to tempTicket) + ).firstOrNull()?.get("id")?.let { (it as Number).toLong() } ?: return@forEach + + group.forEach { r -> + jdbcDao.executeUpdate( + """ + UPDATE fpsmsdb.pick_order + SET deliveryOrderPickOrderId = :headerId + WHERE id = :pickOrderId + """.trimIndent(), + mapOf( + "headerId" to headerId, + "pickOrderId" to r.pickOrderId + ) + ) + } + createdHeaders++ + } + + return createdHeaders + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchTruckRoutingSummaryService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchTruckRoutingSummaryService.kt new file mode 100644 index 0000000..abc6a1e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchTruckRoutingSummaryService.kt @@ -0,0 +1,199 @@ +package com.ffii.fpsms.modules.deliveryOrder.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.deliveryOrder.enums.DoPickOrderStatus +import org.springframework.stereotype.Service + +@Service +class WorkbenchTruckRoutingSummaryService( + private val jdbcDao: JdbcDao, +) { + fun getStoreOptions(): List> { + val sql = """ + SELECT DISTINCT t.Store_id AS value + FROM truck t + WHERE t.deleted = 0 + AND t.Store_id IS NOT NULL + AND TRIM(t.Store_id) <> '' + ORDER BY t.Store_id + """.trimIndent() + return jdbcDao.queryForList(sql, emptyMap()).map { row -> + val rawValue = (row["value"] ?: "").toString().trim() + val label = when (rawValue.uppercase()) { + "2F", "2/F" -> "2/F" + "4F", "4/F" -> "4/F" + else -> rawValue + } + mapOf("label" to label, "value" to rawValue) + } + } + + fun getLaneOptions(storeId: String?): List> { + val args = mutableMapOf() + val storeSql = if (!storeId.isNullOrBlank()) { + args["storeId"] = storeId.replace("/", "") + "AND REPLACE(t.Store_id, '/', '') = :storeId" + } else { + "" + } + val sql = """ + SELECT DISTINCT t.TruckLanceCode AS value + FROM truck t + WHERE t.deleted = 0 + AND t.TruckLanceCode IS NOT NULL + AND TRIM(t.TruckLanceCode) <> '' + $storeSql + ORDER BY t.TruckLanceCode + """.trimIndent() + return jdbcDao.queryForList(sql, args).map { row -> + val value = (row["value"] ?: "").toString().trim() + mapOf("label" to value, "value" to value) + } + } + + fun search(storeId: String?, truckLanceCode: String?, reportDate: String?): List> { + val args = mutableMapOf() + val storeSql = if (!storeId.isNullOrBlank()) { + args["storeId"] = storeId.replace("/", "") + "AND REPLACE(t.Store_id, '/', '') = :storeId" + } else { + "" + } + val laneSql = if (!truckLanceCode.isNullOrBlank()) { + args["truckLanceCode"] = truckLanceCode.trim() + "AND t.TruckLanceCode = :truckLanceCode" + } else { + "" + } + val storeSqlForMax = if (!storeId.isNullOrBlank()) { + "AND REPLACE(t2.Store_id, '/', '') = :storeId" + } else { + "" + } + val laneSqlForMax = if (!truckLanceCode.isNullOrBlank()) { + "AND t2.TruckLanceCode = :truckLanceCode" + } else { + "" + } + val cartonDateSql = if (!reportDate.isNullOrBlank()) { + args["reportDate"] = reportDate.trim() + "AND dop.requiredDeliveryDate = :reportDate" + } else { + "" + } + + val sql = """ + SELECT + CAST( + CASE + WHEN seq_max.maxLoading IS NULL OR t.LoadingSequence IS NULL THEN COALESCE(t.LoadingSequence, 0) + ELSE seq_max.maxLoading - t.LoadingSequence + 1 + END AS CHAR + ) AS dropOffSequence, + COALESCE(s.`code`, '') AS shopCode, + TRIM( + CASE + WHEN LOCATE(' - ', COALESCE(s.name, '')) > 0 + THEN SUBSTRING( + COALESCE(s.name, ''), + LOCATE(' - ', COALESCE(s.name, '')) + 3 + ) + ELSE COALESCE(s.name, '') + END + ) AS shopName, + COALESCE(s.addr3, '') AS address, + CASE + WHEN COALESCE(carton_sum.qty, 0) = 0 THEN '-' + ELSE CAST(carton_sum.qty AS CHAR) + END AS noOfCartons + FROM truck t + LEFT JOIN shop s + ON t.shopId = s.id + AND s.deleted = 0 + LEFT JOIN ( + SELECT dop.truckId, SUM(COALESCE(dop.cartonQty, 0)) AS qty + FROM delivery_order_pick_order dop + WHERE dop.deleted = 0 + AND LOWER(COALESCE(dop.ticketStatus, '')) = 'completed' + $cartonDateSql + GROUP BY dop.truckId + ) carton_sum ON carton_sum.truckId = t.id + CROSS JOIN ( + SELECT MAX(t2.LoadingSequence) AS maxLoading + FROM truck t2 + WHERE t2.deleted = 0 + $storeSqlForMax + $laneSqlForMax + ) seq_max + WHERE t.deleted = 0 + $storeSql + $laneSql + ORDER BY t.LoadingSequence DESC, COALESCE(s.`code`, t.ShopCode) ASC + """.trimIndent() + + val rows = jdbcDao.queryForList(sql, args) + if (rows.isNotEmpty()) return rows + return listOf( + mapOf( + "dropOffSequence" to "", + "shopCode" to "", + "shopName" to "", + "address" to "", + "noOfCartons" to "", + ) + ) + } + + fun getUnpickedOrderCount(storeId: String?, truckLanceCode: String?, reportDate: String?): Int { + if (storeId.isNullOrBlank() || truckLanceCode.isNullOrBlank() || reportDate.isNullOrBlank()) return 0 + val args = mutableMapOf( + "storeId" to storeId.replace("/", ""), + "truckLanceCode" to truckLanceCode.trim(), + "reportDate" to reportDate.trim(), + "pendingStatus" to DoPickOrderStatus.pending.name.lowercase(), + ) + val sql = """ + SELECT COUNT(1) AS unpickedCount + FROM delivery_order_pick_order dop + WHERE dop.deleted = 0 + AND REPLACE(dop.storeId, '/', '') = :storeId + AND dop.truckLanceCode = :truckLanceCode + AND dop.requiredDeliveryDate = :reportDate + AND LOWER(COALESCE(dop.ticketStatus, '')) = :pendingStatus + """.trimIndent() + val row = jdbcDao.queryForList(sql, args).firstOrNull() + return (row?.get("unpickedCount") as? Number)?.toInt() ?: 0 + } + + fun getDepartureTimeLabel(storeId: String?, truckLanceCode: String?): String { + val args = mutableMapOf() + val storeSql = if (!storeId.isNullOrBlank()) { + args["storeId"] = storeId.replace("/", "") + "AND REPLACE(t.Store_id, '/', '') = :storeId" + } else return "" + val laneSql = if (!truckLanceCode.isNullOrBlank()) { + args["truckLanceCode"] = truckLanceCode.trim() + "AND t.TruckLanceCode = :truckLanceCode" + } else return "" + val sql = """ + SELECT DATE_FORMAT(MIN(t.DepartureTime), '%H:%i') AS departureLabel + FROM truck t + WHERE t.deleted = 0 + AND t.DepartureTime IS NOT NULL + $storeSql + $laneSql + """.trimIndent() + val row = jdbcDao.queryForList(sql, args).firstOrNull() + return (row?.get("departureLabel") ?: "").toString().trim() + } + + fun formatStoreIdForDisplay(raw: String?): String { + if (raw.isNullOrBlank()) return "" + val normalized = raw.trim().replace("/", "") + return when (normalized.uppercase()) { + "2F" -> "2/F" + "4F" -> "4/F" + else -> raw.trim() + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt new file mode 100644 index 0000000..4672629 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt @@ -0,0 +1,272 @@ +package com.ffii.fpsms.modules.deliveryOrder.web + +import com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService +import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchScanPickRequest +import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchBatchScanPickRequest +import com.ffii.fpsms.modules.master.web.models.MessageResponse +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.RestController +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.PathVariable +import java.time.LocalDate +import org.springframework.format.annotation.DateTimeFormat +import com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchReleaseService +import com.ffii.fpsms.modules.deliveryOrder.web.models.* +import org.springframework.web.bind.annotation.ModelAttribute +import com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchDopoAssignmentService +import com.ffii.fpsms.modules.deliveryOrder.service.WorkbenchTruckRoutingSummaryService +import com.ffii.fpsms.modules.report.service.ReportService +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import java.time.format.DateTimeFormatter +@RestController +@RequestMapping("/doPickOrder/workbench") +class DoWorkbenchController( + private val doWorkbenchMainService: DoWorkbenchMainService, + private val doWorkbenchReleaseService: DoWorkbenchReleaseService, + private val doWorkbenchDopoAssignmentService: DoWorkbenchDopoAssignmentService, + private val workbenchTruckRoutingSummaryService: WorkbenchTruckRoutingSummaryService, + private val reportService: ReportService, +) { + /** + * Workbench scan-pick: implemented in [com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]. + * Posts outbound on the scanned inventory lot line first; on lot exhaustion before the line chunk is filled + * (without an explicit short [WorkbenchScanPickRequest.qty]) performs split + `pick_order_line.partially_completed`. + * Explicit short qty completes SOL and POL without `partially_completed`. + * Explicit qty may exceed this SOL’s remaining chunk (overscan); actual post is capped by lot availability, then + * rebuild + ensure SOL reconcile the line. + */ + @PostMapping("/scan-pick") + fun scanPick(@RequestBody request: WorkbenchScanPickRequest): MessageResponse { + return doWorkbenchMainService.scanPick(request) + } + + /** + * Batch scan-pick for workbench submit flows (e.g. close noLot/expired/unavailable with qty=0). + * Returns SUCCESS when at least one line is processed; each line response is returned in entity.lines. + */ + @PostMapping("/scan-pick/batch") + fun scanPickBatch(@RequestBody request: WorkbenchBatchScanPickRequest): MessageResponse { + val lines = request.lines + if (lines.isEmpty()) { + return MessageResponse( + id = null, + name = "scan_pick_batch", + code = "EMPTY", + type = "workbench_scan_pick_batch", + message = "No lines", + errorPosition = null, + entity = mapOf("lines" to emptyList()), + ) + } + val results = lines.map { doWorkbenchMainService.scanPick(it) } + val anySuccess = results.any { (it.code ?: "").equals("SUCCESS", ignoreCase = true) } + return MessageResponse( + id = null, + name = "scan_pick_batch", + code = if (anySuccess) "SUCCESS" else "FAILED", + type = "workbench_scan_pick_batch", + message = if (anySuccess) "Batch scan-pick processed" else "Batch scan-pick failed", + errorPosition = null, + entity = mapOf( + "lines" to results, + "total" to results.size, + "success" to results.count { (it.code ?: "").equals("SUCCESS", ignoreCase = true) }, + "failed" to results.count { !(it.code ?: "").equals("SUCCESS", ignoreCase = true) }, + ), + ) + } + + /** Lane grid from `delivery_order_pick_order` + linked `pick_order` (workbench FG assign view). */ + @GetMapping("/summary-by-store") + fun getWorkbenchSummaryByStore( + @RequestParam storeId: String, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, + @RequestParam releaseType: String? + ): StoreLaneSummary { + return doWorkbenchMainService.getDeliveryOrderPickOrderSummaryByStore( + storeId, + requiredDate, + releaseType ?: "all" + ) + } + + /** Past-date backlog tickets from `delivery_order_pick_order` (not `do_pick_order`). */ + @GetMapping("/released") + fun getWorkbenchReleasedDoPickOrders( + @RequestParam(required = false) shopName: String?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) truck: String? + ): List { + return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(shopName, storeId, truck) + } + + @GetMapping("/released-today") + fun getWorkbenchReleasedDoPickOrdersToday( + @RequestParam(required = false) shopName: String?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) truck: String? + ): List { + return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(shopName, storeId, truck) + } + + @PostMapping("/assign-by-delivery-order-pick-order-id") + fun assignByDeliveryOrderPickOrderId(@RequestBody request: AssignByDeliveryOrderPickOrderIdRequest): MessageResponse { + return doWorkbenchDopoAssignmentService.assignByDeliveryOrderPickOrderId(request) + } + + /** Assign V1 (legacy): old FG-style, no atomic conflict guard. */ + @PostMapping("/assign-by-delivery-order-pick-order-id-v1") + fun assignByDeliveryOrderPickOrderIdV1(@RequestBody request: AssignByDeliveryOrderPickOrderIdRequest): MessageResponse { + return doWorkbenchDopoAssignmentService.assignByDeliveryOrderPickOrderIdV1(request) + } + + /** Lane assign for workbench FG (uses `delivery_order_pick_order`, not `do_pick_order`). */ + @PostMapping("/assign-by-lane") + fun assignByLaneWorkbench(@RequestBody request: AssignByLaneRequest): MessageResponse { + return doWorkbenchDopoAssignmentService.assignByLaneForWorkbench(request) + } + + /** Lane assign V1 (legacy): old FG-style, no atomic conflict guard. */ + @PostMapping("/assign-by-lane-v1") + fun assignByLaneWorkbenchV1(@RequestBody request: AssignByLaneRequest): MessageResponse { + return doWorkbenchDopoAssignmentService.assignByLaneForWorkbenchV1(request) + } + + @PostMapping("/batch-release/async") + fun startWorkbenchBatchReleaseAsync( + @RequestBody ids: List, + @RequestParam(defaultValue = "1") userId: Long + ): MessageResponse { + return doWorkbenchReleaseService.startBatchReleaseAsync(ids, userId) + } + + /** + * V2: [DeliveryOrderService.releaseDeliveryOrderWithoutTicketNoHoldV2] — no stock out header at release; + * suggested pick + stock out + lines run when assigning the workbench ticket ([DoWorkbenchDopoAssignmentService]). + */ + @PostMapping("/batch-release/async-v2") + fun startWorkbenchBatchReleaseAsyncV2( + @RequestBody ids: List, + @RequestParam(defaultValue = "1") userId: Long + ): MessageResponse { + return doWorkbenchReleaseService.startBatchReleaseAsyncV2(ids, userId) + } + + /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ + @PostMapping("/batch-release/sync-v2") + fun workbenchBatchReleaseSyncV2( + @RequestBody ids: List, + @RequestParam(defaultValue = "1") userId: Long + ): MessageResponse { + return doWorkbenchReleaseService.releaseBatchV2(ids, userId) + } + + @GetMapping("/batch-release/progress/{jobId}") + fun getWorkbenchBatchReleaseProgress(@PathVariable jobId: String): MessageResponse { + return doWorkbenchReleaseService.getBatchReleaseProgress(jobId) + } + + @GetMapping("/ticket-release-table/{startDate}&{endDate}") + fun getWorkbenchTicketReleaseTable( + @PathVariable startDate: LocalDate, + @PathVariable endDate: LocalDate, + ): List { + return doWorkbenchMainService.getWorkbenchTicketReleaseTable(startDate, endDate) + } + + /** Workbench-only force complete by delivery_order_pick_order.id (does not touch do_pick_order headers). */ + @PostMapping("/force-complete/{deliveryOrderPickOrderId}") + fun forceCompleteWorkbenchTicket(@PathVariable deliveryOrderPickOrderId: Long): MessageResponse { + return doWorkbenchMainService.forceCompleteWorkbenchTicket(deliveryOrderPickOrderId) + } + + /** Workbench-only revert assignment by delivery_order_pick_order.id. */ + @PostMapping("/revert-assignment/{deliveryOrderPickOrderId}") + fun revertWorkbenchTicketAssignment(@PathVariable deliveryOrderPickOrderId: Long): MessageResponse { + return doWorkbenchMainService.revertWorkbenchTicketAssignment(deliveryOrderPickOrderId) + } + + @GetMapping("/completed-lot-details/{deliveryOrderPickOrderId}") + fun getCompletedLotDetails( + @PathVariable deliveryOrderPickOrderId: Long, + ): Map { + return doWorkbenchMainService.getCompletedLotDetailsByDeliveryOrderPickOrderId(deliveryOrderPickOrderId) + } + @GetMapping("/print-DN") + fun printWorkbenchDN(@ModelAttribute request: PrintDeliveryNoteRequest) { + doWorkbenchMainService.printDeliveryNoteWorkbench(request) + } + + @GetMapping("/print-DNLabels") + fun printWorkbenchDNLabels(@ModelAttribute request: PrintDNLabelsRequest) { + doWorkbenchMainService.printDNLabelsWorkbench(request) + } + + @GetMapping("/truck-routing-summary/store-options") + fun getWorkbenchTruckRoutingStoreOptions(): List> = + workbenchTruckRoutingSummaryService.getStoreOptions() + + @GetMapping("/truck-routing-summary/lane-options") + fun getWorkbenchTruckRoutingLaneOptions( + @RequestParam(required = false) storeId: String?, + ): List> = + workbenchTruckRoutingSummaryService.getLaneOptions(storeId) + + @GetMapping("/truck-routing-summary/precheck") + fun precheckWorkbenchTruckRoutingSummary( + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) truckLanceCode: String?, + @RequestParam(required = false) date: String?, + ): Map { + val reportDate = if (date.isNullOrBlank()) { + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + } else { + date + } + val unpickedCount = workbenchTruckRoutingSummaryService.getUnpickedOrderCount(storeId, truckLanceCode, reportDate) + return mapOf( + "unpickedOrderCount" to unpickedCount, + "hasUnpickedOrders" to (unpickedCount > 0), + ) + } + + @GetMapping("/truck-routing-summary/print") + fun printWorkbenchTruckRoutingSummary( + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) truckLanceCode: String?, + @RequestParam(required = false) date: String?, + ): ResponseEntity { + val reportDate = if (date.isNullOrBlank()) { + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + } else { + date + } + val departureLabel = workbenchTruckRoutingSummaryService.getDepartureTimeLabel(storeId, truckLanceCode) + val lane = (truckLanceCode ?: "").trim() + val params = mutableMapOf( + "FLOOR_LABEL" to workbenchTruckRoutingSummaryService.formatStoreIdForDisplay(storeId), + "TRUCK_ROUTE" to lane, + "DEPARTURE_TIME" to departureLabel, + "REPORT_DATE" to reportDate, + ) + val rows = workbenchTruckRoutingSummaryService.search(storeId, truckLanceCode, reportDate) + val pdfBytes = reportService.createPdfResponse( + "/DeliveryNote/TruckRoutingSummaryPDF.jrxml", + params, + rows + ) + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_PDF + setContentDispositionFormData("attachment", "TruckRoutingSummary.pdf") + set("filename", "TruckRoutingSummary.pdf") + } + return ResponseEntity(pdfBytes, headers, HttpStatus.OK) + } + +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchScanPickRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchScanPickRequest.kt new file mode 100644 index 0000000..4012430 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchScanPickRequest.kt @@ -0,0 +1,11 @@ +package com.ffii.fpsms.modules.deliveryOrder.web.models + +/** + * Batch wrapper for workbench scan-pick. + * + * Each line is processed independently (partial success possible). + */ +data class WorkbenchBatchScanPickRequest( + val lines: List = emptyList(), +) + diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt new file mode 100644 index 0000000..f53b9db --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt @@ -0,0 +1,30 @@ +package com.ffii.fpsms.modules.deliveryOrder.web.models + +import java.math.BigDecimal + +/** + * Workbench v1: scan lot and post pick immediately (no separate submit step). + * [qty] optional: when null, posts up to remaining quantity for this stock-out line chunk; when set, may exceed that + * chunk and is capped only by available quantity on the scanned inventory lot line (overscan / UI edit). + */ +data class WorkbenchScanPickRequest( + val stockOutLineId: Long, + val lotNo: String, + /** When set (e.g. from QR), resolves `inventory_lot` via `stockInLineId` instead of newest row by lotNo+itemId. */ + val stockInLineId: Long? = null, + /** Optional store scope (e.g. 2/F, 2F). When set, split re-suggestions must stay within the same store. */ + val storeId: String? = null, + /** + * Optional: exclude these warehouse codes from any (re)suggest logic triggered by this scan. + * Case-insensitive. + */ + val excludeWarehouseCodes: List? = null, + /** + * When set (e.g. label-print modal row), resolves this exact `inventory_lot_line` instead of + * [stockInLineId] / newest-by-lotNo. Disambiguates duplicate lot numbers. + */ + val inventoryLotLineId: Long? = null, + val qty: BigDecimal? = null, + val userId: Long = 1L, +) + diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt new file mode 100644 index 0000000..d0a504d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt @@ -0,0 +1,24 @@ +package com.ffii.fpsms.modules.deliveryOrder.web.models + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +data class WorkbenchTicketReleaseTableResponse( + val deliveryOrderPickOrderId: Long, + val storeId: String?, + val ticketNo: String?, + val loadingSequence: Int?, + val ticketStatus: String?, + val truckDepartureTime: LocalTime?, + val handledBy: Long?, + val ticketReleaseTime: LocalDateTime?, + val ticketCompleteDateTime: LocalDateTime?, + val truckLanceCode: String?, + val shopCode: String?, + val shopName: String?, + val requiredDeliveryDate: LocalDate?, + val handlerName: String?, + val numberOfFGItems: Int = 0, + val isActiveWorkbenchTicket: Boolean = false, +) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/jobOrderMatching.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/jobOrderMatching.kt new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt new file mode 100644 index 0000000..cde8ee4 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt @@ -0,0 +1,556 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository +import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository +import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus +import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoResponse +import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderLotsHierarchicalResponse +import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse +import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoResponse +import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsResponse +import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse +import com.ffii.fpsms.modules.master.web.models.MessageResponse +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderType +import com.ffii.fpsms.modules.stock.entity.InventoryLotLine +import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository +import com.ffii.fpsms.modules.stock.entity.InventoryLotRepository +import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository +import com.ffii.fpsms.modules.stock.entity.StockOutRepository +import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository +import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus +import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService +import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService +import com.ffii.fpsms.modules.user.service.UserService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.time.LocalDate +import java.util.Locale + +/** + * Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI). + * [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick). + * Non-workbench hierarchical stays in [JoPickOrderService.getJobOrderLotsHierarchicalByPickOrderId] (in − out − hold). + */ +@Service +open class JoWorkbenchMainService( + private val jobOrderRepository: JobOrderRepository, + private val pickOrderRepository: PickOrderRepository, + private val pickOrderLineRepository: PickOrderLineRepository, + private val suggestPickLotRepository: SuggestPickLotRepository, + private val inventoryLotLineRepository: InventoryLotLineRepository, + private val inventoryLotRepository: InventoryLotRepository, + private val stockOutLineRepository: StockOutLIneRepository, + private val joPickOrderRepository: JoPickOrderRepository, + private val userService: UserService, + private val stockOutRepository: StockOutRepository, + private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, + private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, +) { + // Keep aligned with SuggestedPickLotWorkbenchService default excludes for diagnosis logs. + private val workbenchDefaultExcludeWarehouseCodes: Set = setOf( + "2F-W202-01-00", + "2F-W200-#A-00", + ) + + private fun debugPrintSuggestionNullReasons(pickOrderId: Long) { + val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return + val lines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrderId) + if (lines.isEmpty()) return + + val today = LocalDate.now() + val lineIds = lines.mapNotNull { it.id } + val splByLine = suggestPickLotRepository + .findAllByPickOrderLineIdInAndDeletedFalse(lineIds) + .groupBy { it.pickOrderLine?.id } + + println("=== JO_WORKBENCH_SUGGEST_DEBUG pickOrderId=$pickOrderId ===") + lines.forEach { line -> + val lineId = line.id ?: return@forEach + val itemId = line.item?.id + if (itemId == null) { + println("JO_SUGGEST_DEBUG polId=$lineId itemId=null reason=missing_item") + return@forEach + } + + val allItemLots = inventoryLotLineRepository.findAllByItemIdIn(listOf(itemId)) + val notDeleted = allItemLots.filter { !it.deleted } + val availableStatus = notDeleted.filter { it.status == InventoryLotLineStatus.AVAILABLE } + val notExpired = availableStatus.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } + val notExcluded = notExpired.filter { lot -> + val code = lot.warehouse?.code?.trim()?.uppercase(Locale.ROOT) + code.isNullOrEmpty() || code !in workbenchDefaultExcludeWarehouseCodes + } + val positiveQty = notExcluded.filter { lot -> + val inQ = lot.inQty ?: BigDecimal.ZERO + val outQ = lot.outQty ?: BigDecimal.ZERO + inQ.subtract(outQ) > BigDecimal.ZERO + } + + val activeSpls = splByLine[lineId].orEmpty() + val hasNonNullSuggestedLot = activeSpls.any { it.suggestedLotLine?.id != null } + val suggestedLotLineId = activeSpls.firstOrNull { it.suggestedLotLine?.id != null }?.suggestedLotLine?.id + + println( + "JO_SUGGEST_DEBUG polId=$lineId itemId=$itemId req=${line.qty} " + + "lots[item=${allItemLots.size},notDeleted=${notDeleted.size},available=${availableStatus.size}," + + "notExpired=${notExpired.size},notExcluded=${notExcluded.size},positiveQty=${positiveQty.size}] " + + "splCount=${activeSpls.size} suggestedLotLineId=${suggestedLotLineId ?: "null"}" + ) + + if (!hasNonNullSuggestedLot) { + val reason = when { + allItemLots.isEmpty() -> "no_item_lot_rows" + notDeleted.isEmpty() -> "all_deleted" + availableStatus.isEmpty() -> "none_status_available" + notExpired.isEmpty() -> "all_expired" + notExcluded.isEmpty() -> "all_excluded_by_default_warehouse" + positiveQty.isEmpty() -> "none_positive_available_qty" + else -> "eligible_lot_exists_but_spl_still_null_check_sync_or_query_timing" + } + val sampleCodes = positiveQty.take(3).joinToString(",") { it.warehouse?.code ?: "-" } + val sampleLots = positiveQty.take(3).joinToString(",") { it.inventoryLot?.lotNo ?: "-" } + println( + "JO_SUGGEST_DEBUG_NULL polId=$lineId reason=$reason " + + "sampleWarehouses=[$sampleCodes] sampleLots=[$sampleLots]" + ) + } + } + } + + private data class SplDebugRow( + val splId: Long?, + val polId: Long?, + val lotLineId: Long?, + val qty: BigDecimal?, + val deleted: Boolean, + ) + + private fun loadSplDebugRowsByPol(pickOrderId: Long): Map> { + val lineIds = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrderId) + .mapNotNull { it.id } + if (lineIds.isEmpty()) return emptyMap() + return suggestPickLotRepository.findAllByPickOrderLineIdIn(lineIds) + .map { spl -> + SplDebugRow( + splId = spl.id, + polId = spl.pickOrderLine?.id, + lotLineId = spl.suggestedLotLine?.id, + qty = spl.qty, + deleted = spl.deleted, + ) + } + .filter { it.polId != null } + .groupBy { it.polId!! } + } + + private fun debugPrintAssignSplDiff( + pickOrderId: Long, + before: Map>, + after: Map>, + ) { + println("=== JO_ASSIGN_SPL_DIFF pickOrderId=$pickOrderId ===") + val allPolIds = (before.keys + after.keys).toSortedSet() + allPolIds.forEach { polId -> + val beforeRows = before[polId].orEmpty().sortedBy { it.splId ?: 0L } + val afterRows = after[polId].orEmpty().sortedBy { it.splId ?: 0L } + val beforeFmt = if (beforeRows.isEmpty()) { + "[]" + } else { + beforeRows.joinToString( + prefix = "[", + postfix = "]", + separator = ", ", + ) { r -> + "spl=${r.splId}|lot=${r.lotLineId ?: "null"}|qty=${r.qty ?: "null"}|del=${r.deleted}" + } + } + val afterFmt = if (afterRows.isEmpty()) { + "[]" + } else { + afterRows.joinToString( + prefix = "[", + postfix = "]", + separator = ", ", + ) { r -> + "spl=${r.splId}|lot=${r.lotLineId ?: "null"}|qty=${r.qty ?: "null"}|del=${r.deleted}" + } + } + println("JO_ASSIGN_SPL_DIFF polId=$polId before=$beforeFmt -> after=$afterFmt") + } + } + + + /** Workbench / scan-pick: same as [com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService] lot availability. */ + private fun illAvailableQtyWorkbench(ill: InventoryLotLine?): BigDecimal? { + if (ill == null || ill.deleted) return null + val inQ = ill.inQty ?: BigDecimal.ZERO + val outQ = ill.outQty ?: BigDecimal.ZERO + return inQ.subtract(outQ) + } + + /** + * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. + */ + open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalResponse { + println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") + println("pickOrderId: $pickOrderId") + + return try { + val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) + if (pickOrder == null || pickOrder.deleted == true) { + return emptyHierarchical("Pick order $pickOrderId not found or deleted") + } + + val jobOrder = pickOrder.jobOrder + ?: return emptyHierarchical("Pick order has no job order") + + val pickOrderLines = pickOrderLineRepository.findAllByPickOrderId(pickOrder.id!!) + .filter { it.deleted == false } + + val itemIds = pickOrderLines.mapNotNull { it.item?.id }.distinct() + val today = LocalDate.now() + val totalAvailableQtyByItemId: Map = if (itemIds.isNotEmpty()) { + inventoryLotLineRepository.findAllByItemIdIn(itemIds) + .asSequence() + .filter { it.deleted == false } + .filter { it.status == InventoryLotLineStatus.AVAILABLE } + .filter { ill -> + val exp = ill.inventoryLot?.expiryDate + exp == null || !exp.isBefore(today) + } + .mapNotNull { ill -> + val iid = ill.inventoryLot?.item?.id ?: return@mapNotNull null + val remaining = illAvailableQtyWorkbench(ill) ?: return@mapNotNull null + if (remaining > BigDecimal.ZERO) iid to remaining else null + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, qtys) -> qtys.fold(BigDecimal.ZERO) { acc, q -> acc + q }.toDouble() } + } else { + emptyMap() + } + + val pickOrderLineIds = pickOrderLines.map { it.id!! } + + val suggestedPickLots = if (pickOrderLineIds.isNotEmpty()) { + suggestPickLotRepository.findAllByPickOrderLineIdIn(pickOrderLineIds) + .filter { it.deleted == false } + } else { + emptyList() + } + + val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id } + + val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { + inventoryLotLineRepository.findAllByIdIn(inventoryLotLineIds) + .filter { it.deleted == false } + } else { + emptyList() + } + + val inventoryLotIds = inventoryLotLines.mapNotNull { it.inventoryLot?.id }.distinct() + if (inventoryLotIds.isNotEmpty()) { + inventoryLotRepository.findAllByIdIn(inventoryLotIds).filter { it.deleted == false } + } + + val stockOutLines = if (pickOrderLineIds.isNotEmpty() && inventoryLotLineIds.isNotEmpty()) { + pickOrderLineIds.flatMap { polId -> + inventoryLotLineIds.flatMap { illId -> + stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse(polId, illId) + } + } + } else { + emptyList() + } + + val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId -> + stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) + } + + val stockOutInventoryLotLineIds = stockOutLinesByPickOrderLine.values + .flatten() + .mapNotNull { it.inventoryLotLineId } + .distinct() + + val stockOutInventoryLotLines = if (stockOutInventoryLotLineIds.isNotEmpty()) { + inventoryLotLineRepository.findAllByIdIn(stockOutInventoryLotLineIds) + .filter { it.deleted == false } + } else { + emptyList() + } + + val inventoryLotLineById = (inventoryLotLines + stockOutInventoryLotLines) + .filter { it.id != null } + .associateBy { it.id!! } + + val stockInLinesByInventoryLotLineId: Map = buildMap { + inventoryLotLines.forEach { ill -> + val id = ill.id ?: return@forEach + ill.inventoryLot?.stockInLine?.id?.let { put(id, it) } + } + stockOutInventoryLotLines.forEach { ill -> + val id = ill.id ?: return@forEach + if (!containsKey(id)) { + ill.inventoryLot?.stockInLine?.id?.let { put(id, it) } + } + } + } + + val joPickOrders = joPickOrderRepository.findByPickOrderId(pickOrder.id!!) + + val pickOrderInfo = PickOrderInfoResponse( + id = pickOrder.id, + code = pickOrder.code, + consoCode = pickOrder.consoCode, + targetDate = pickOrder.targetDate?.let { + "${it.year}-${String.format("%02d", it.monthValue)}-${String.format("%02d", it.dayOfMonth)}" + }, + type = pickOrder.type?.value, + status = pickOrder.status?.value, + assignTo = pickOrder.assignTo?.id, + jobOrder = JobOrderBasicInfoResponse( + id = jobOrder.id!!, + code = jobOrder.code ?: "", + name = "Job Order ${jobOrder.code}" + ) + ) + + val pickOrderLinesResult = pickOrderLines.map { pol -> + val item = pol.item + val uom = pol.uom + val lineId = pol.id!! + val lineSuggestedLots = suggestedPickLots.filter { it.pickOrderLine?.id == pol.id } + val jpo = joPickOrders.firstOrNull { it.itemId == item?.id } + val handlerName = jpo?.handledBy?.let { uid -> + userService.find(uid).orElse(null)?.name + } + + val lots = lineSuggestedLots.mapNotNull { spl -> + val ill = spl.suggestedLotLine + if (ill == null || ill.deleted == true) return@mapNotNull null + + val il = ill.inventoryLot + if (il == null || il.deleted == true) return@mapNotNull null + + val warehouse = ill.warehouse + val sol = stockOutLines.firstOrNull { + it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id + } + val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id } + val handlerNameInner = jpoInner?.handledBy?.let { uid -> + userService.find(uid).orElse(null)?.name + } + println("handlerName: $handlerNameInner") + val availableQty = if (sol?.status == "rejected") { + null + } else { + illAvailableQtyWorkbench(ill) + } + + val lotAvailability = when { + il.expiryDate != null && il.expiryDate!!.isBefore(LocalDate.now()) -> "expired" + sol?.status == "rejected" -> "rejected" + ill.status == InventoryLotLineStatus.UNAVAILABLE -> "status_unavailable" + availableQty != null && availableQty <= BigDecimal.ZERO -> "insufficient_stock" + else -> "available" + } + + val processingStatus = when (sol?.status) { + "completed" -> "completed" + "rejected" -> "rejected" + "created" -> "pending" + else -> "pending" + } + + val stockInLineId = ill.id?.let { illId -> + stockInLinesByInventoryLotLineId[illId] + } + + LotDetailResponse( + lotId = ill.id, + lotNo = il.lotNo, + expiryDate = il.expiryDate?.let { + "${it.year}-${String.format("%02d", it.monthValue)}-${String.format("%02d", it.dayOfMonth)}" + }, + location = warehouse?.code, + availableQty = availableQty?.toDouble(), + requiredQty = spl.qty?.toDouble() ?: 0.0, + actualPickQty = sol?.qty ?: 0.0, + processingStatus = processingStatus, + lotAvailability = lotAvailability, + pickOrderId = pickOrder.id, + pickOrderCode = pickOrder.code, + pickOrderConsoCode = pickOrder.consoCode, + pickOrderLineId = pol.id, + stockOutLineId = sol?.id, + stockInLineId = stockInLineId, + suggestedPickLotId = spl.id, + stockOutLineQty = sol?.qty ?: 0.0, + stockOutLineStatus = sol?.status, + routerIndex = warehouse?.order?.toString(), + routerArea = warehouse?.code, + routerRoute = warehouse?.code, + uomShortDesc = uom?.udfShortDesc, + matchStatus = jpoInner?.matchStatus?.value, + matchBy = jpoInner?.matchBy, + matchQty = jpoInner?.matchQty?.toDouble() + ) + } + + val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol -> + val illId = sol.inventoryLotLineId + val ill = if (illId != null) inventoryLotLineById[illId] else null + val lot = ill?.inventoryLot + val warehouse = ill?.warehouse + val availableQty = if (sol.status == "rejected") { + null + } else if (ill == null || ill.deleted == true) { + null + } else { + illAvailableQtyWorkbench(ill) + } + val splForSol = lineSuggestedLots.firstOrNull { sp -> + sp.suggestedLotLine?.id != null && sp.suggestedLotLine?.id == illId + } + + StockOutLineDetailResponse( + id = sol.id, + status = sol.status, + qty = sol.qty.toDouble(), + lotId = illId, + lotNo = sol.lotNo ?: lot?.lotNo, + location = warehouse?.code, + availableQty = availableQty?.toDouble(), + noLot = (illId == null), + suggestedPickQty = splForSol?.qty?.toDouble(), + suggestedPickLotId = splForSol?.id, + ) + } + + PickOrderLineWithLotsResponse( + id = pol.id!!, + itemId = item?.id, + itemCode = item?.code, + itemName = item?.name, + requiredQty = pol.qty?.toDouble(), + totalAvailableQty = item?.id?.let { totalAvailableQtyByItemId[it] }, + uomCode = uom?.code, + uomDesc = uom?.udfudesc, + status = pol.status?.value, + lots = lots, + stockouts = stockouts, + handler = handlerName + ) + } + + JobOrderLotsHierarchicalResponse( + pickOrder = pickOrderInfo, + pickOrderLines = pickOrderLinesResult + ) + } catch (e: Exception) { + println("❌ Error in getJobOrderLotsHierarchicalByPickOrderIdWorkbench: ${e.message}") + e.printStackTrace() + emptyHierarchical(e.message ?: "Error") + } + } + + private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalResponse { + println("❌ $message") + return JobOrderLotsHierarchicalResponse( + pickOrder = PickOrderInfoResponse( + id = null, + code = null, + consoCode = null, + targetDate = null, + type = null, + status = null, + assignTo = null, + jobOrder = JobOrderBasicInfoResponse(0, "", "") + ), + pickOrderLines = emptyList() + ) + } + + /** + * Workbench assign gate: + * - only JO status = pending can trigger assign/prime + * - after successful assign, move JO status to packaging to avoid re-assign on re-enter + */ + @Transactional + open fun assignJobOrderPickOrderToUserForWorkbench(pickOrderId: Long, userId: Long): MessageResponse { + println("=== assignJobOrderPickOrderToUserForWorkbench ===") + println("pickOrderId: $pickOrderId, userId: $userId") + + return try { + val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) + if (pickOrder == null) { + return MessageResponse( + id = null, + code = null, + name = null, + type = null, + message = "Pick order not found", + errorPosition = null + ) + } + + if (pickOrder.type == PickOrderType.JOB_ORDER) { + val jobOrder = pickOrder.jobOrder + if (jobOrder == null) { + return MessageResponse( + id = pickOrder.id, + code = pickOrder.code, + name = null, + type = null, + message = "Job order not found for pick order", + errorPosition = null, + ) + } + if (jobOrder.status != JobOrderStatus.PENDING) { + return MessageResponse( + id = pickOrder.id, + code = pickOrder.code, + name = jobOrder.bom?.name, + type = null, + message = "Skipped assign: job order status is ${jobOrder.status?.value}, only pending can assign", + errorPosition = null, + ) + } + val splBefore = loadSplDebugRowsByPol(pickOrderId) + suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( + pickOrderId = pickOrderId, + storeId = null, + excludeWarehouseCodes = null, + ) + stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId) + val splAfter = loadSplDebugRowsByPol(pickOrderId) + debugPrintAssignSplDiff(pickOrderId, splBefore, splAfter) + debugPrintSuggestionNullReasons(pickOrderId) + jobOrder.status = JobOrderStatus.PACKAGING + jobOrderRepository.save(jobOrder) + } + + MessageResponse( + id = pickOrder.id, + code = pickOrder.code, + name = pickOrder.jobOrder?.bom?.name, + type = null, + message = "Successfully assigned", + errorPosition = null + ) + } catch (e: Exception) { + println("❌ Error in assignJobOrderPickOrderToUserForWorkbench: ${e.message}") + e.printStackTrace() + MessageResponse( + id = null, + code = null, + name = null, + type = null, + message = "Error occurred: ${e.message}", + errorPosition = null + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchReleaseService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchReleaseService.kt new file mode 100644 index 0000000..ab258d2 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchReleaseService.kt @@ -0,0 +1,21 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import com.ffii.fpsms.modules.master.web.models.MessageResponse +import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderCommonActionRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * Job Order Workbench release: deferred stock out / SPL / SOL until first workbench assign. + * Normal release remains on [JobOrderService.releaseJobOrder] via `/jo/release`. + */ +@Service +open class JoWorkbenchReleaseService( + private val jobOrderService: JobOrderService, +) { + + @Transactional + open fun releaseJobOrderForWorkbench(request: JobOrderCommonActionRequest): MessageResponse { + return jobOrderService.releaseJobOrderForWorkbench(request) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt index df3b09c..7addc6b 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt @@ -243,7 +243,7 @@ open class JobOrderService( var sufficientCount = 0 var insufficientCount = 0 - println("=== JobOrderService.calculateStockCounts for JobOrder: ${jobOrder.code} ===") + //println("=== JobOrderService.calculateStockCounts for JobOrder: ${jobOrder.code} ===") nonConsumablesJobms.forEach { jobm -> val itemId = jobm.item?.id @@ -328,7 +328,7 @@ open class JobOrderService( } } - println("=== Result: sufficient=$sufficientCount, insufficient=$insufficientCount ===") + //println("=== Result: sufficient=$sufficientCount, insufficient=$insufficientCount ===") return Pair(sufficientCount, insufficientCount) } @@ -530,7 +530,19 @@ open class JobOrderService( @Transactional(rollbackFor = [Exception::class]) open fun releaseJobOrder(request: JobOrderCommonActionRequest): MessageResponse { - val jo = request.id.let { jobOrderRepository.findById(it).getOrNull() } ?: throw NoSuchElementException() + return releaseJobOrderInternal(request.id, deferStockOutUntilAssign = false) + } + + @Transactional(rollbackFor = [Exception::class]) + open fun releaseJobOrderForWorkbench(request: JobOrderCommonActionRequest): MessageResponse { + return releaseJobOrderInternal(request.id, deferStockOutUntilAssign = true) + } + + private fun releaseJobOrderInternal( + jobOrderId: Long, + deferStockOutUntilAssign: Boolean, + ): MessageResponse { + val jo = jobOrderId.let { jobOrderRepository.findById(it).getOrNull() } ?: throw NoSuchElementException() jo.apply { status = JobOrderStatus.PENDING } @@ -607,6 +619,7 @@ open class JobOrderService( pickOrderEntity.consoCode = consoCode pickOrderEntity.status = com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus.RELEASED pickOrderRepository.saveAndFlush(pickOrderEntity) + if (!deferStockOutUntilAssign) { // Create stock out record and pre-create stock out lines val stockOut = StockOut().apply { this.type = "job" @@ -681,6 +694,9 @@ open class JobOrderService( } } } + } else { + println("JO workbench release defer enabled: skip legacy suggestion/hold/stockout prebuild. pickOrderId=${pickOrderEntity.id}") + } } val itemIds = pols.mapNotNull { it.itemId } if (itemIds.isNotEmpty()) { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt index 0e8b010..462c7b9 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt @@ -20,8 +20,8 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import com.ffii.fpsms.modules.jobOrder.service.JoPickOrderService -//import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchMainService -//import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchReleaseService +import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchMainService +import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchReleaseService import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus import org.springframework.format.annotation.DateTimeFormat @@ -55,8 +55,8 @@ class JobOrderController( private val jobOrderBomMaterialService: JobOrderBomMaterialService, private val jobOrderProcessService: JobOrderProcessService, private val joPickOrderService: JoPickOrderService, - //private val joWorkbenchMainService: JoWorkbenchMainService, - //private val joWorkbenchReleaseService: JoWorkbenchReleaseService, + private val joWorkbenchMainService: JoWorkbenchMainService, + private val joWorkbenchReleaseService: JoWorkbenchReleaseService, private val productProcessService: ProductProcessService, private val jobOrderCreationService: com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService ) { @@ -96,12 +96,12 @@ class JobOrderController( } /** Workbench: release without stock out / SPL / SOL until first pick-order assign. */ - /* + @PostMapping("/workbench/release") fun releaseJobOrderForWorkbench(@Valid @RequestBody request: JobOrderCommonActionRequest): MessageResponse { return joWorkbenchReleaseService.releaseJobOrderForWorkbench(request) } - */ + @PostMapping("/set-hidden") fun setJobOrderHidden(@Valid @RequestBody request: SetJobOrderHiddenRequest): MessageResponse { return jobOrderService.setJobOrderHidden(request) @@ -150,7 +150,7 @@ class JobOrderController( } /** Workbench: assign + prime SPL/SOL when release was deferred (see [JoWorkbenchMainService]). */ - /* + @PostMapping("/workbench/assign-job-order-pick-order/{pickOrderId}/{userId}") fun assignJobOrderPickOrderToUserForWorkbench( @PathVariable pickOrderId: Long, @@ -158,7 +158,7 @@ class JobOrderController( ): MessageResponse { return joWorkbenchMainService.assignJobOrderPickOrderToUserForWorkbench(pickOrderId, userId) } - */ + @PostMapping("/unassign-job-order-pick-order/{pickOrderId}") fun unAssignJobOrderPickOrderToUser( @PathVariable pickOrderId: Long @@ -315,12 +315,12 @@ fun getJobOrderPickOrderLotDetails( } /** Workbench: available qty uses in−out (matches scan-pick); stockouts include suggested SPL qty when matched. */ - /* + @GetMapping("/all-lots-hierarchical-by-pick-order-workbench/{pickOrderId}") fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalResponse { return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId) } - */ + @PostMapping("/update-jo-pick-order-handled-by") fun updateJoPickOrderHandledBy(@Valid @RequestBody request: UpdateJoPickOrderHandledByRequest): MessageResponse { try { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt index 818e56d..dbcb012 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt @@ -119,7 +119,10 @@ data class StockOutLineDetailResponse( val lotNo: String?, val location: String?, val availableQty: Double?, - val noLot: Boolean + val noLot: Boolean, + /** Matched `suggest_pick_lot.qty` for this SOL’s `inventory_lot_line` (null if no SPL row). */ + val suggestedPickQty: Double? = null, + val suggestedPickLotId: Long? = null, ) data class LotDetailResponse( diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/JobOrderActionRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/JobOrderActionRequest.kt index 529932d..a0ad103 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/JobOrderActionRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/JobOrderActionRequest.kt @@ -2,6 +2,12 @@ package com.ffii.fpsms.modules.jobOrder.web.model data class JobOrderCommonActionRequest( val id: Long, + /** + * When true (Jo Workbench release): create pick order + Jo tickets only — no [StockOut], + * suggested pick lots, hold qty, or stock out lines. Those are created on first + * workbench assign, aligned with DO workbench. + */ + val deferStockOutUntilAssign: Boolean? = null, ) data class JobOrderUpdateRequest( diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt index 7eb572d..4c8c012 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt @@ -33,6 +33,7 @@ interface PickOrderRepository : AbstractRepository { and (lower(:itemName) = 'all' or lower(pol.item.name) like concat('%',lower(:itemName),'%')) ) ) + and (:assignTo is null or (po.assignTo is not null and po.assignTo.id = :assignTo)) and po.consoCode = null and po.deleted = false """ @@ -44,6 +45,7 @@ interface PickOrderRepository : AbstractRepository { type: String, status: String, itemName: String, + assignTo: Long?, pageable: Pageable, ): Page diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt new file mode 100644 index 0000000..5eb8f01 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt @@ -0,0 +1,360 @@ +package com.ffii.fpsms.modules.pickOrder.service + +import com.ffii.core.support.JdbcDao +import java.util.Locale +import kotlin.system.measureTimeMillis + +/** + * Shared hierarchical FG payload (fgInfo + merged pickOrders with lines/lots) for both + * legacy [do_pick_order_line] and workbench [delivery_order_pick_order] flows. + */ +fun assembleHierarchicalFgPayload( + jdbcDao: JdbcDao, + doPickOrderId: Long, + doPickOrderInfo: Map, + pickOrdersInfo: List>, + /** DO workbench: treat pickable qty as in - out (ignore hold). Legacy FG uses in - out - hold. */ + availableQtyInOutOnly: Boolean = false, + /** Optional perf sink for endpoint-level timing visibility. */ + timingSink: ((String, Long) -> Unit)? = null, +): Map { + val availExpr = + if (availableQtyInOutOnly) { + "(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0))" + } else { + "(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0))" + } + val doTicketStatus = doPickOrderInfo["doTicketStatus"] + + val allPickOrderLines = mutableListOf>() + val lineCountsPerPickOrder = mutableListOf() + val pickOrderIds = mutableListOf() + val pickOrderCodes = mutableListOf() + val doOrderIds = mutableListOf() + val deliveryOrderCodes = mutableListOf() + + val poRows = pickOrdersInfo.mapNotNull { poInfo -> + val pickOrderId = (poInfo["pick_order_id"] as? Number)?.toLong() ?: return@mapNotNull null + val pickOrderCode = poInfo["pick_order_code"] as? String + val doOrderId = (poInfo["do_order_id"] as? Number)?.toLong() + val deliveryOrderCode = poInfo["delivery_order_code"] as? String + pickOrderCode?.let { pickOrderCodes.add(it) } + doOrderId?.let { doOrderIds.add(it) } + deliveryOrderCode?.let { deliveryOrderCodes.add(it) } + pickOrderIds.add(pickOrderId) + pickOrderId to poInfo + } + + var linesQueryTotalMs = 0L + var lineTransformTotalMs = 0L + var allLinesResults: List> = emptyList() + if (pickOrderIds.isNotEmpty()) { + val inClause = pickOrderIds.joinToString(",") + val linesSql = """ + WITH target_pol AS ( + SELECT pol.id + FROM fpsmsdb.pick_order_line pol + JOIN fpsmsdb.pick_order po ON po.id = pol.poId + WHERE po.id IN ($inClause) + AND po.deleted = false + AND pol.deleted = false +), +ll AS ( + SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId + FROM fpsmsdb.suggested_pick_lot spl + JOIN target_pol tp ON tp.id = spl.pickOrderLineId + WHERE spl.deleted = false + + UNION + + SELECT sol.pickOrderLineId, sol.inventoryLotLineId AS lotLineId + FROM fpsmsdb.stock_out_line sol + JOIN target_pol tp ON tp.id = sol.pickOrderLineId + WHERE sol.deleted = false +), +sm AS ( + SELECT s.pickOrderLineId, s.suggestedLotLineId, MAX(s.id) AS maxId + FROM fpsmsdb.suggested_pick_lot s + JOIN target_pol tp ON tp.id = s.pickOrderLineId + WHERE s.deleted = false + GROUP BY s.pickOrderLineId, s.suggestedLotLineId +) +SELECT + po.id as pickOrderId, + po.code as pickOrderCode, + po.consoCode as pickOrderConsoCode, + DATE_FORMAT(po.targetDate, '%Y-%m-%d') as pickOrderTargetDate, + po.type as pickOrderType, + po.status as pickOrderStatus, + po.assignTo as pickOrderAssignTo, + + pol.id as pickOrderLineId, + pol.qty as pickOrderLineRequiredQty, + pol.status as pickOrderLineStatus, + + i.id as itemId, + i.code as itemCode, + i.name as itemName, + i.item_Order as itemOrder, + uc.code as uomCode, + uc.udfudesc as uomDesc, + uc.udfShortDesc as uomShortDesc, + + ill.id as lotId, + il.lotNo, + DATE_FORMAT(il.expiryDate, '%Y-%m-%d') as expiryDate, + w.name as location, + COALESCE(uc.udfudesc, 'N/A') as stockUnit, + il.stockInLineId as stockInLineId, + w.`order` as routerIndex, + w.code as routerRoute, + + CASE + WHEN sol.status = 'rejected' THEN NULL + ELSE $availExpr + END as availableQty, + + COALESCE(spl.qty, sol.qty, 0) as requiredQty, + COALESCE(sol.qty, 0) as actualPickQty, + spl.id as suggestedPickLotId, + ill.status as lotStatus, + sol.id as stockOutLineId, + sol.status as stockOutLineStatus, + COALESCE(sol.qty, 0) as stockOutLineQty, + COALESCE(ill.inQty, 0) as inQty, + COALESCE(ill.outQty, 0) as outQty, + COALESCE(ill.holdQty, 0) as holdQty, + + CASE + WHEN (il.expiryDate IS NOT NULL AND il.expiryDate < CURDATE()) THEN 'expired' + WHEN sol.status = 'rejected' THEN 'rejected' + WHEN LOWER(TRIM(COALESCE(sol.status, ''))) IN ('completed', 'partially_completed') THEN 'available' + WHEN $availExpr <= 0 THEN 'insufficient_stock' + WHEN ill.status = 'unavailable' THEN 'status_unavailable' + ELSE 'available' + END as lotAvailability, + + CASE + WHEN sol.status = 'completed' THEN 'completed' + WHEN sol.status = 'rejected' THEN 'rejected' + WHEN sol.status = 'created' THEN 'pending' + ELSE 'pending' + END as processingStatus +FROM fpsmsdb.pick_order po +JOIN fpsmsdb.pick_order_line pol ON pol.poId = po.id AND pol.deleted = false +JOIN fpsmsdb.items i ON i.id = pol.itemId +LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId + +LEFT JOIN ll ON ll.pickOrderLineId = pol.id + +LEFT JOIN sm + ON sm.pickOrderLineId = pol.id + AND sm.suggestedLotLineId <=> ll.lotLineId +LEFT JOIN fpsmsdb.suggested_pick_lot spl + ON spl.id = sm.maxId + AND spl.deleted = false + +LEFT JOIN fpsmsdb.stock_out_line sol + ON sol.pickOrderLineId = pol.id + AND ((sol.inventoryLotLineId = ll.lotLineId) + OR (sol.inventoryLotLineId IS NULL AND ll.lotLineId IS NULL)) + AND sol.deleted = false + +LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = ll.lotLineId AND ill.deleted = false +LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = false +LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId +WHERE po.id IN ($inClause) + AND po.deleted = false +ORDER BY + po.id ASC, + COALESCE(w.`order`, 999999) ASC, + pol.id ASC, + il.lotNo ASC + """.trimIndent() + + linesQueryTotalMs = measureTimeMillis { + allLinesResults = jdbcDao.queryForList(linesSql) + } + timingSink?.invoke("assemble.batchQueryMs", linesQueryTotalMs) + } + + val linesByPickOrderId = allLinesResults.groupBy { (it["pickOrderId"] as? Number)?.toLong() ?: -1L } + + poRows.forEach { (pickOrderId, _) -> + val linesResults = linesByPickOrderId[pickOrderId].orEmpty() + var pickOrderLines: List> = emptyList() + val transformMs = measureTimeMillis { + val lineGroups = linesResults.groupBy { (it["pickOrderLineId"] as? Number)?.toLong() } + + pickOrderLines = lineGroups.map { (lineId, lineRows) -> + val firstLineRow = lineRows.firstOrNull() + + if (firstLineRow == null) { + null + } else { + val lots = if (lineRows.any { it["lotId"] != null }) { + lineRows.filter { it["lotId"] != null }.map { lotRow -> + mapOf( + "id" to lotRow["lotId"], + "lotNo" to lotRow["lotNo"], + "expiryDate" to lotRow["expiryDate"], + "location" to lotRow["location"], + "stockUnit" to lotRow["stockUnit"], + "availableQty" to lotRow["availableQty"], + "requiredQty" to lotRow["requiredQty"], + "actualPickQty" to lotRow["actualPickQty"], + "inQty" to lotRow["inQty"], + "outQty" to lotRow["outQty"], + "holdQty" to lotRow["holdQty"], + "lotStatus" to lotRow["lotStatus"], + "lotAvailability" to lotRow["lotAvailability"], + "processingStatus" to lotRow["processingStatus"], + "suggestedPickLotId" to lotRow["suggestedPickLotId"], + "stockOutLineId" to lotRow["stockOutLineId"], + "stockOutLineStatus" to lotRow["stockOutLineStatus"], + "stockOutLineQty" to lotRow["stockOutLineQty"], + "stockInLineId" to lotRow["stockInLineId"], + "router" to mapOf( + "id" to null, + "index" to lotRow["routerIndex"], + "route" to lotRow["routerRoute"], + "area" to lotRow["routerRoute"], + "itemCode" to lotRow["itemId"], + "itemName" to lotRow["itemName"], + "uomId" to lotRow["uomCode"], + "noofCarton" to lotRow["requiredQty"], + ), + ) + } + } else { + emptyList() + } + + val stockouts = lineRows + .filter { it["stockOutLineId"] != null } + .distinctBy { row -> row["stockOutLineId"] } + .map { row -> + val lotId = row["lotId"] + val noLot = (lotId == null) + mapOf( + "id" to row["stockOutLineId"], + "status" to row["stockOutLineStatus"], + "qty" to row["stockOutLineQty"], + "lotId" to lotId, + "lotNo" to (row["lotNo"] ?: ""), + "location" to (row["location"] ?: ""), + "availableQty" to row["availableQty"], + "stockInLineId" to row["stockInLineId"], + "noLot" to noLot, + ) + } + + mapOf( + "id" to lineId, + "requiredQty" to firstLineRow["pickOrderLineRequiredQty"], + "status" to firstLineRow["pickOrderLineStatus"], + "itemOrder" to firstLineRow["itemOrder"], + "item" to mapOf( + "id" to firstLineRow["itemId"], + "code" to firstLineRow["itemCode"], + "name" to firstLineRow["itemName"], + "uomCode" to firstLineRow["uomCode"], + "uomDesc" to firstLineRow["uomDesc"], + "uomShortDesc" to firstLineRow["uomShortDesc"], + ), + "lots" to lots, + "stockouts" to stockouts, + ) + } + }.filterNotNull() + } + lineTransformTotalMs += transformMs + timingSink?.invoke("assemble.po.${pickOrderId ?: 0}.transformMs", transformMs) + + lineCountsPerPickOrder.add(pickOrderLines.size) + allPickOrderLines.addAll(pickOrderLines) + } + + val doStoreFloorKey = doPickOrderInfo["store_id"]?.toString()?.trim()?.uppercase(Locale.ROOT) + ?.replace("/", "") + ?.replace(" ", "") + ?: "" + val sortMs = measureTimeMillis { + when (doStoreFloorKey) { + "2F" -> { + allPickOrderLines.sortWith( + compareBy( + { line -> + val v = line["itemOrder"] ?: line["itemorder"] + when (v) { + is Number -> v.toInt() + else -> 999999 + } + }, + { line -> (line["id"] as? Number)?.toLong() ?: Long.MAX_VALUE }, + ), + ) + } + "4F" -> { + } + else -> { + allPickOrderLines.sortWith( + compareBy { line -> + val lots = line["lots"] as? List> + val firstLot = lots?.firstOrNull() + val router = firstLot?.get("router") as? Map + val indexValue = router?.get("index") + when (indexValue) { + is Number -> indexValue.toInt() + is String -> { + val parts = indexValue.split("-") + if (parts.size > 1) { + parts.last().toIntOrNull() ?: 999999 + } else { + indexValue.toIntOrNull() ?: 999999 + } + } + else -> 999999 + } + }, + ) + } + } + } + timingSink?.invoke("assemble.linesQueryTotalMs", linesQueryTotalMs) + timingSink?.invoke("assemble.lineTransformTotalMs", lineTransformTotalMs) + timingSink?.invoke("assemble.sortMs", sortMs) + + val fgInfo = mapOf( + "doPickOrderId" to doPickOrderId, + "ticketNo" to doPickOrderInfo["ticket_no"], + "storeId" to doPickOrderInfo["store_id"], + "shopCode" to doPickOrderInfo["ShopCode"], + "shopName" to doPickOrderInfo["ShopName"], + "truckLanceCode" to doPickOrderInfo["TruckLanceCode"], + "departureTime" to doPickOrderInfo["truck_departure_time"], + ) + val allConsoCodes = pickOrdersInfo.mapNotNull { it["consoCode"] as? String }.distinct() + + val mergedPickOrder = if (pickOrdersInfo.isNotEmpty()) { + val firstPickOrderInfo = pickOrdersInfo.first() + mapOf( + "pickOrderIds" to pickOrderIds, + "pickOrderCodes" to pickOrderCodes, + "doOrderIds" to doOrderIds, + "deliveryOrderCodes" to deliveryOrderCodes, + "lineCountsPerPickOrder" to lineCountsPerPickOrder, + "consoCodes" to allConsoCodes, + "status" to doTicketStatus, + "targetDate" to firstPickOrderInfo["targetDate"], + "pickOrderLines" to allPickOrderLines, + ) + } else { + null + } + + return mapOf( + "fgInfo" to fgInfo, + "pickOrders" to listOfNotNull(mergedPickOrder), + ) +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt index b1b20ec..7cba195 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt @@ -167,6 +167,7 @@ open class PickOrderService( type = request.type ?: "all", status = request.status ?: "all", itemName = request.itemName ?: "all", + assignTo = request.assignTo, pageable = pageable ) @@ -187,6 +188,7 @@ open class PickOrderService( type = request.type ?: "all", status = request.status ?: "all", itemName = request.itemName ?: "all", + assignTo = request.assignTo, pageable = pageable ) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt new file mode 100644 index 0000000..46906bf --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt @@ -0,0 +1,404 @@ +package com.ffii.fpsms.modules.pickOrder.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.master.web.models.MessageResponse +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus +import com.ffii.fpsms.modules.pickOrder.web.models.PickOrderLineLotDetailResponse +import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService +import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService +import com.ffii.fpsms.modules.user.service.UserService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.time.LocalDate + +@Service +open class PickOrderWorkbenchService( + private val jdbcDao: JdbcDao, + private val pickOrderRepository: PickOrderRepository, + private val pickOrderLineRepository: PickOrderLineRepository, + private val userService: UserService, + private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, + private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, +) { + private fun toBigDecimal(v: Any?): BigDecimal? = when (v) { + null -> null + is BigDecimal -> v + is Number -> BigDecimal(v.toString()) + is String -> v.toBigDecimalOrNull() + else -> null + } + + private fun toLong(v: Any?): Long? = when (v) { + null -> null + is Number -> v.toLong() + is String -> v.toLongOrNull() + else -> null + } + + private fun toLocalDate(v: Any?): LocalDate? = when (v) { + null -> null + is LocalDate -> v + is java.sql.Date -> v.toLocalDate() + is java.sql.Timestamp -> v.toLocalDateTime().toLocalDate() + is String -> runCatching { LocalDate.parse(v) }.getOrNull() + else -> null + } + + /** + * Workbench assign V2 for consumable pick orders: + * - Assign selected pick orders to user and mark ASSIGNED. + * - Build no-hold suggestions + ensure stock_out_line for each pick order. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun assignPickOrdersV2(pickOrderIds: List, assignTo: Long): MessageResponse { + try { + val pickOrders = pickOrderRepository.findAllById(pickOrderIds) + if (pickOrders.isEmpty()) { + return MessageResponse( + id = null, + name = "No pick orders found", + code = "ERROR", + type = "pickorder_workbench", + message = "No pick orders found with the provided IDs", + errorPosition = null + ) + } + + val user = userService.find(assignTo).orElse(null) + pickOrders.forEach { pickOrder -> + pickOrder.assignTo = user + pickOrder.status = PickOrderStatus.ASSIGNED + } + pickOrderRepository.saveAll(pickOrders) + + val assignedIds = mutableListOf() + var suggestionRows = 0 + var stockOutLines = 0 + pickOrders.forEach { po -> + val poId = po.id ?: return@forEach + val suggestSummary = suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( + pickOrderId = poId, + storeId = null, + excludeWarehouseCodes = null, + ) + val solSummary = stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, assignTo) + assignedIds.add(poId) + suggestionRows += suggestSummary.created + stockOutLines += solSummary.created + } + + return MessageResponse( + id = null, + name = "Workbench assign v2 success", + code = "SUCCESS", + type = "pickorder_workbench", + message = "Assigned and initialized no-hold suggestion/stock-out for workbench", + errorPosition = null, + entity = mapOf( + "pickOrderIds" to assignedIds, + "suggestionRowsCreated" to suggestionRows, + "stockOutLinesCreated" to stockOutLines + ) + ) + } catch (e: Exception) { + return MessageResponse( + id = null, + name = "Workbench assign v2 failed", + code = "ERROR", + type = "pickorder_workbench", + message = "Failed to assign workbench v2: ${e.message}", + errorPosition = null + ) + } + } + + /** + * Workbench release V2 for consumable pick orders: + * no suggestion/hold side effects, only status transition to RELEASED. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun releasePickOrdersV2(pickOrderIds: List, assignTo: Long): MessageResponse { + try { + val pickOrders = pickOrderRepository.findAllById(pickOrderIds) + if (pickOrders.isEmpty()) { + return MessageResponse( + id = null, + name = "No pick orders found", + code = "ERROR", + type = "pickorder_workbench", + message = "No pick orders found with the provided IDs", + errorPosition = null + ) + } + val user = userService.find(assignTo).orElse(null) + pickOrders.forEach { po -> + po.assignTo = user + po.status = PickOrderStatus.RELEASED + } + pickOrderRepository.saveAll(pickOrders) + return MessageResponse( + id = null, + name = "Workbench release v2 success", + code = "SUCCESS", + type = "pickorder_workbench", + message = "Released pick orders with no-hold flow", + errorPosition = null, + entity = mapOf("pickOrderIds" to pickOrders.mapNotNull { it.id }) + ) + } catch (e: Exception) { + return MessageResponse( + id = null, + name = "Workbench release v2 failed", + code = "ERROR", + type = "pickorder_workbench", + message = "Failed to release workbench v2: ${e.message}", + errorPosition = null + ) + } + } + + /** + * Consumable workbench hierarchical payload for Tab3. + * Uses no-hold available qty semantics (inQty - outQty). + */ + open fun getAllPickOrderLotsWithDetailsHierarchicalWorkbench(userId: Long): Map { + val pickOrders = pickOrderRepository.findAllByAssignToIdAndStatusIn( + assignToId = userId, + statuses = listOf(PickOrderStatus.ASSIGNED, PickOrderStatus.RELEASED, PickOrderStatus.PICKING), + ).filter { + it.type?.value?.lowercase() != "do" && it.type?.value?.lowercase() != "jo" + } + + if (pickOrders.isEmpty()) { + return mapOf("fgInfo" to null, "pickOrders" to emptyList()) + } + + val out = pickOrders.map { po -> + val poId = po.id ?: 0L + val lines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(poId) + val linePayload = lines.map { pol -> + val polId = pol.id ?: 0L + val sql = """ + SELECT + sol.id AS stockOutLineId, + sol.status AS stockOutLineStatus, + COALESCE(sol.qty, 0) AS stockOutLineQty, + ill.id AS lotId, + il.lotNo AS lotNo, + il.stockInLineId AS stockInLineId, + DATE_FORMAT(il.expiryDate, '%Y-%m-%d') AS expiryDate, + w.name AS location, + COALESCE(ill.inQty,0) AS inQty, + COALESCE(ill.outQty,0) AS outQty, + COALESCE(ill.holdQty,0) AS holdQty, + (COALESCE(ill.inQty,0) - COALESCE(ill.outQty,0)) AS availableQty + FROM fpsmsdb.stock_out_line sol + LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = sol.inventoryLotLineId + LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId + LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId + WHERE sol.pickOrderLineId = :pickOrderLineId + AND sol.deleted = false + ORDER BY sol.id + """.trimIndent() + val lotRows = jdbcDao.queryForList(sql, mapOf("pickOrderLineId" to polId)) + val lots = lotRows.map { r -> + mapOf( + "id" to r["lotId"], + "lotNo" to r["lotNo"], + "expiryDate" to r["expiryDate"], + "location" to r["location"], + "stockUnit" to (pol.uom?.udfudesc ?: pol.uom?.code ?: ""), + "availableQty" to r["availableQty"], + "requiredQty" to pol.qty, + "actualPickQty" to r["stockOutLineQty"], + "inQty" to r["inQty"], + "outQty" to r["outQty"], + "holdQty" to r["holdQty"], + "lotStatus" to "available", + "lotAvailability" to "available", + "processingStatus" to (r["stockOutLineStatus"] ?: "pending"), + "stockOutLineId" to r["stockOutLineId"], + "stockOutLineStatus" to r["stockOutLineStatus"], + "stockOutLineQty" to r["stockOutLineQty"], + "stockInLineId" to r["stockInLineId"], + ) + } + mapOf( + "id" to polId, + "requiredQty" to pol.qty, + "status" to pol.status?.value, + "item" to mapOf( + "id" to pol.item?.id, + "code" to pol.item?.code, + "name" to pol.item?.name, + "uomCode" to pol.uom?.code, + "uomDesc" to pol.uom?.udfudesc, + ), + "lots" to lots, + ) + } + mapOf( + "pickOrderId" to poId, + "pickOrderCode" to (po.code ?: ""), + "status" to po.status?.value, + "targetDate" to po.targetDate?.toLocalDate()?.toString(), + "pickOrderLines" to linePayload, + ) + } + + return mapOf( + "fgInfo" to mapOf("mode" to "consumable-workbench", "userId" to userId), + "pickOrders" to out, + ) + } + + /** + * Workbench line detail V2 (consumable): + * - Returns only lot rows already linked by suggestion/stock_out_line for this pick order line. + * - Uses no-hold availability semantics: available = inQty - outQty. + * - Includes no-lot stock_out_line rows. + */ + open fun getWorkbenchPickOrderLineLotDetailsV2(pickOrderLineId: Long): List { + val zero = BigDecimal.ZERO + val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return emptyList() + val uomDesc = pol.uom?.udfudesc ?: pol.uom?.code + + val lotSql = """ + SELECT + ill.id AS lotId, + il.lotNo AS lotNo, + il.expiryDate AS expiryDate, + w.code AS location, + COALESCE(ill.inQty,0) AS inQty, + COALESCE(ill.outQty,0) AS outQty, + COALESCE(ill.holdQty,0) AS holdQty, + (COALESCE(ill.inQty,0) - COALESCE(ill.outQty,0)) AS availableQty, + spl.suggestedPickLotId AS suggestedPickLotId, + spl.requiredQty AS suggestedQty, + ill.status AS lotStatus, + sol.stockOutLineId AS stockOutLineId, + sol.stockOutLineStatus AS stockOutLineStatus, + COALESCE(sol.stockOutLineQty,0) AS stockOutLineQty + FROM fpsmsdb.inventory_lot_line ill + LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId + LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId + LEFT JOIN ( + SELECT + s.suggestedLotLineId AS lotLineId, + MAX(s.id) AS suggestedPickLotId, + SUM(COALESCE(s.qty,0)) AS requiredQty + FROM fpsmsdb.suggested_pick_lot s + WHERE s.pickOrderLineId = :pickOrderLineId + AND s.deleted = false + AND s.suggestedLotLineId IS NOT NULL + GROUP BY s.suggestedLotLineId + ) spl ON spl.lotLineId = ill.id + LEFT JOIN ( + SELECT + so.inventoryLotLineId AS lotLineId, + MAX(so.id) AS stockOutLineId, + SUBSTRING_INDEX(GROUP_CONCAT(so.status ORDER BY so.id DESC), ',', 1) AS stockOutLineStatus, + SUM(COALESCE(so.qty,0)) AS stockOutLineQty + FROM fpsmsdb.stock_out_line so + WHERE so.pickOrderLineId = :pickOrderLineId + AND so.deleted = false + AND so.inventoryLotLineId IS NOT NULL + GROUP BY so.inventoryLotLineId + ) sol ON sol.lotLineId = ill.id + WHERE ill.id IN ( + SELECT DISTINCT s.suggestedLotLineId + FROM fpsmsdb.suggested_pick_lot s + WHERE s.pickOrderLineId = :pickOrderLineId + AND s.deleted = false + AND s.suggestedLotLineId IS NOT NULL + UNION + SELECT DISTINCT so.inventoryLotLineId + FROM fpsmsdb.stock_out_line so + WHERE so.pickOrderLineId = :pickOrderLineId + AND so.deleted = false + AND so.inventoryLotLineId IS NOT NULL + ) + ORDER BY il.expiryDate, il.lotNo + """.trimIndent() + + val lotRows = jdbcDao.queryForList(lotSql, mapOf("pickOrderLineId" to pickOrderLineId)) + val withLot = lotRows.map { r -> + val inQty = toBigDecimal(r["inQty"]) ?: zero + val outQty = toBigDecimal(r["outQty"]) ?: zero + val holdQty = toBigDecimal(r["holdQty"]) ?: zero + val availableQty = toBigDecimal(r["availableQty"]) ?: inQty.subtract(outQty) + val status = (r["stockOutLineStatus"]?.toString() ?: "").lowercase() + val lotAvailability = when { + status == "rejected" -> "rejected" + (r["lotStatus"]?.toString() ?: "").lowercase() == "unavailable" -> "status_unavailable" + availableQty <= zero -> "insufficient_stock" + else -> "available" + } + PickOrderLineLotDetailResponse( + lotId = toLong(r["lotId"]), + lotNo = r["lotNo"]?.toString(), + expiryDate = toLocalDate(r["expiryDate"]), + location = r["location"]?.toString(), + stockUnit = uomDesc, + availableQty = availableQty, + requiredQty = toBigDecimal(r["suggestedQty"]) ?: pol.qty ?: zero, + inQty = inQty, + outQty = outQty, + holdQty = holdQty, + actualPickQty = toBigDecimal(r["stockOutLineQty"]) ?: zero, + suggestedPickLotId = toLong(r["suggestedPickLotId"]), + lotStatus = r["lotStatus"]?.toString(), + stockOutLineId = toLong(r["stockOutLineId"]), + stockOutLineStatus = r["stockOutLineStatus"]?.toString(), + stockOutLineQty = toBigDecimal(r["stockOutLineQty"]) ?: zero, + totalPickedByAllPickOrders = toBigDecimal(r["stockOutLineQty"]) ?: zero, + remainingAfterAllPickOrders = availableQty, + lotAvailability = lotAvailability, + noLot = false, + ) + } + + val noLotSql = """ + SELECT + so.id AS stockOutLineId, + so.status AS stockOutLineStatus, + COALESCE(so.qty,0) AS stockOutLineQty + FROM fpsmsdb.stock_out_line so + WHERE so.pickOrderLineId = :pickOrderLineId + AND so.deleted = false + AND so.inventoryLotLineId IS NULL + ORDER BY so.id + """.trimIndent() + val noLotRows = jdbcDao.queryForList(noLotSql, mapOf("pickOrderLineId" to pickOrderLineId)) + val noLot = noLotRows.map { r -> + PickOrderLineLotDetailResponse( + lotId = null, + lotNo = null, + expiryDate = null, + location = null, + stockUnit = uomDesc, + availableQty = null, + requiredQty = pol.qty ?: zero, + inQty = null, + outQty = null, + holdQty = null, + actualPickQty = toBigDecimal(r["stockOutLineQty"]) ?: zero, + suggestedPickLotId = null, + lotStatus = null, + stockOutLineId = toLong(r["stockOutLineId"]), + stockOutLineStatus = r["stockOutLineStatus"]?.toString(), + stockOutLineQty = toBigDecimal(r["stockOutLineQty"]) ?: zero, + totalPickedByAllPickOrders = null, + remainingAfterAllPickOrders = null, + lotAvailability = "available", + noLot = true, + ) + } + + return withLot + noLot + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt index f74c5c8..ded41ce 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt @@ -7,6 +7,7 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository import com.ffii.fpsms.modules.pickOrder.entity.projection.PickOrderInfo import com.ffii.fpsms.modules.pickOrder.service.PickOrderService +import com.ffii.fpsms.modules.pickOrder.service.PickOrderWorkbenchService import com.ffii.fpsms.modules.pickOrder.web.models.* import com.ffii.fpsms.modules.pickOrder.web.models.ConsoPickOrderRequest import com.ffii.fpsms.modules.pickOrder.web.models.ConsoPickOrderResponse @@ -35,11 +36,14 @@ import java.time.LocalDate import com.ffii.fpsms.modules.pickOrder.entity.projection.PickOrderGroupInfo import com.ffii.fpsms.modules.pickOrder.web.models.GetPickOrderInfoResponse import com.ffii.fpsms.modules.pickOrder.web.models.LotSubstitutionConfirmRequest +import com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService @RestController @RequestMapping("/pickOrder") class PickOrderController( private val pickOrderService: PickOrderService, + private val pickOrderWorkbenchService: PickOrderWorkbenchService, private val pickOrderRepository: PickOrderRepository, + private val doWorkbenchMainService: DoWorkbenchMainService, ) { @GetMapping("/list") fun allPickOrders(): List { @@ -77,6 +81,30 @@ class PickOrderController( return pickOrderService.assignPickOrders(pickOrderIds, assignTo) } + @PostMapping("/workbench/assign-v2") + fun assignPickOrdersWorkbenchV2(@RequestBody request: Map): MessageResponse { + val pickOrderIds = (request["pickOrderIds"] as List<*>).map { it.toString().toLong() } + val assignTo = request["assignTo"].toString().toLong() + return pickOrderWorkbenchService.assignPickOrdersV2(pickOrderIds, assignTo) + } + + @PostMapping("/workbench/release-v2") + fun releasePickOrdersWorkbenchV2(@RequestBody request: Map): MessageResponse { + val pickOrderIds = (request["pickOrderIds"] as List<*>).map { it.toString().toLong() } + val assignTo = request["assignTo"].toString().toLong() + return pickOrderWorkbenchService.releasePickOrdersV2(pickOrderIds, assignTo) + } + + @GetMapping("/workbench/all-lots-hierarchical/{userId}") + fun getAllPickOrderLotsHierarchicalWorkbenchConsumable(@PathVariable userId: Long): Map { + return pickOrderWorkbenchService.getAllPickOrderLotsWithDetailsHierarchicalWorkbench(userId) + } + + @GetMapping("/workbench/line-detail-v2/{pickOrderLineId}") + fun getWorkbenchPickOrderLineDetailV2(@PathVariable pickOrderLineId: Long): List { + return pickOrderWorkbenchService.getWorkbenchPickOrderLineLotDetailsV2(pickOrderLineId) + } + // Release Pick Orders (without consoCode) @PostMapping("/release") fun releasePickOrders(@RequestBody request: Map): MessageResponse { @@ -273,9 +301,19 @@ class PickOrderController( return pickOrderService.getAllPickOrderLotsWithDetailsWithoutAutoAssign(userId) } @GetMapping("/all-lots-hierarchical/{userId}") -fun getAllPickOrderLotsHierarchical(@PathVariable userId: Long): Map { - return pickOrderService.getAllPickOrderLotsWithDetailsHierarchical(userId) -} + fun getAllPickOrderLotsHierarchical(@PathVariable userId: Long): Map { + return pickOrderService.getAllPickOrderLotsWithDetailsHierarchical(userId) + } + + @GetMapping("/fg-pick-orders-workbench/{userId}") + fun getFgPickOrdersByUserIdWorkbench(@PathVariable userId: Long): List> { + return doWorkbenchMainService.getFgPickOrdersByUserIdWorkbench(userId) + } + + @GetMapping("/all-lots-hierarchical-workbench/{userId}") + fun getAllPickOrderLotsHierarchicalWorkbench(@PathVariable userId: Long): Map { + return doWorkbenchMainService.getAllPickOrderLotsWithDetailsHierarchicalWorkbench(userId) + } /* @PostMapping("/auto-assign-release-by-ticket") fun autoAssignAndReleasePickOrderByTicket( @RequestParam storeId: String, @@ -311,6 +349,39 @@ fun getCompletedDoPickOrders( return pickOrderService.getCompletedDoPickOrders(userId, request) } +@GetMapping("/completed-do-pick-orders-workbench/{userId}") +fun getCompletedDoPickOrdersWorkbench( + @PathVariable userId: Long, + @RequestParam(required = false) shopName: String?, + @RequestParam(required = false) targetDate: String?, + @RequestParam(required = false) deliveryNoteCode: String?, + @RequestParam(required = false) truckLanceCode: String?, +): List { + val request = GetCompletedDoPickOrdersRequest( + targetDate = targetDate, + shopName = shopName, + deliveryNoteCode = deliveryNoteCode, + truckLanceCode = truckLanceCode, + ) + return doWorkbenchMainService.getCompletedDoPickOrdersWorkbench(userId, request) +} + +@GetMapping("/completed-do-pick-orders-workbench-all") +fun getCompletedDoPickOrdersWorkbenchAll( + @RequestParam(required = false) shopName: String?, + @RequestParam(required = false) targetDate: String?, + @RequestParam(required = false) deliveryNoteCode: String?, + @RequestParam(required = false) truckLanceCode: String?, +): List { + val request = GetCompletedDoPickOrdersRequest( + targetDate = targetDate, + shopName = shopName, + deliveryNoteCode = deliveryNoteCode, + truckLanceCode = truckLanceCode, + ) + return doWorkbenchMainService.getCompletedDoPickOrdersWorkbenchAll(request) +} + @GetMapping("/completed-do-pick-orders-all") fun getCompletedDoPickOrdersAll( @RequestParam(required = false) shopName: String?, diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt index 7113821..e80da6f 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt @@ -14,6 +14,8 @@ data class SearchPickOrderRequest ( val itemName: String?, val pageSize: Int?, val pageNum: Int?, + /** When set, restrict to pick orders assigned to this user id. */ + val assignTo: Long? = null, ) data class GetCompletedDoPickOrdersRequest( val targetDate: String? = null, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index a326281..6c453cf 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -30,20 +30,19 @@ interface InventoryLotLineRepository : AbstractRepository 0 - THEN 'available' - ELSE 'unavailable' - END, - ill.version = ill.version + 1, - ill.modified = CURRENT_TIMESTAMP - WHERE ill.id = :id - AND ill.deleted = 0 - AND ill.version = :expectedVersion - AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) >= :delta + UPDATE inventory_lot_line ill +SET ill.outQty = COALESCE(ill.outQty, 0) + :delta, + ill.status = CASE + WHEN (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0 + THEN 'available' + ELSE 'unavailable' + END, + ill.version = ill.version + 1, + ill.modified = CURRENT_TIMESTAMP +WHERE ill.id = :id + AND ill.deleted = 0 + AND ill.version = :expectedVersion + AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) >= :delta """, nativeQuery = true ) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLIneRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLIneRepository.kt index ec644a4..7e09d40 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLIneRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLIneRepository.kt @@ -58,6 +58,18 @@ fun findAllByPickOrderLineIdInAndDeletedFalse( @Param("pickOrderLineIds") pickOrderLineIds: List ): List + /** Workbench: bulk load SOL as projection to avoid N+1 in suggestion rebuild. */ + @Query( + """ + SELECT sol FROM StockOutLine sol + WHERE sol.pickOrderLine.id IN :pickOrderLineIds + AND sol.deleted = false + """ + ) + fun findAllInfoByPickOrderLineIdInAndDeletedFalse( + @Param("pickOrderLineIds") pickOrderLineIds: List + ): List + // 添加批量查询方法:按 (pickOrderLineId, inventoryLotLineId) 组合查询 @Query(""" SELECT sol FROM StockOutLine sol diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index eceaaab..5515b69 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -38,6 +38,7 @@ import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse import com.ffii.fpsms.modules.stock.web.model.ScannedLotInfo import com.ffii.fpsms.modules.stock.web.model.SameItemLotInfo +import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse import com.ffii.fpsms.modules.jobOrder.service.JobOrderService import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest import com.ffii.fpsms.modules.master.service.PrinterService @@ -106,7 +107,17 @@ open class InventoryLotLineService( else -> InventoryLotLineStatus.UNAVAILABLE } } - + fun deriveInventoryLotLineStatusForWorkbench( + inQty: BigDecimal?, + outQty: BigDecimal? + ): InventoryLotLineStatus { + val remaining = (inQty ?: BigDecimal.ZERO) - (outQty ?: BigDecimal.ZERO) + return if (remaining > BigDecimal.ZERO) { + InventoryLotLineStatus.AVAILABLE + } else { + InventoryLotLineStatus.UNAVAILABLE + } + } open fun saveInventoryLotLine(request: SaveInventoryLotLineRequest): InventoryLotLine { val inventoryLotLine = request.id?.let { inventoryLotLineRepository.findById(it).getOrNull() } ?: InventoryLotLine() @@ -404,65 +415,123 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit } } -open fun analyzeQrCode(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { - val stockInLine = stockInLineRepository.findById(request.stockInLineId).orElseThrow() - - // Try direct link first; fall back to first lot line in the linked inventoryLot - val scannedInventoryLotLine = - stockInLine.inventoryLotLine - ?: stockInLine.inventoryLot?.inventoryLotLines?.firstOrNull() - ?: throw IllegalStateException("No inventory lot line found for stockInLineId=${request.stockInLineId}") - - val item = scannedInventoryLotLine.inventoryLot?.item - ?: throw IllegalStateException("Item not found for lot line id=${scannedInventoryLotLine.id}") - - // Collect same-item available lots; skip the scanned one; only remainingQty > 0 - val sameItemLots = inventoryLotLineRepository - .findAllByInventoryLotItemIdAndStatus(request.itemId, InventoryLotLineStatus.AVAILABLE) - .asSequence() - .filter { it.id != scannedInventoryLotLine.id } - .mapNotNull { lotLine -> - val lot = lotLine.inventoryLot ?: return@mapNotNull null - val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null - val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: return@mapNotNull null - val whCode = lotLine.warehouse?.code - val whName = lotLine.warehouse?.name - - val inQty = lotLine.inQty ?: BigDecimal.ZERO - val outQty = lotLine.outQty ?: BigDecimal.ZERO - val holdQty = lotLine.holdQty ?: BigDecimal.ZERO - val remainingQty = inQty.minus(outQty).minus(holdQty) - - if (remainingQty > BigDecimal.ZERO) + /** + * Legacy / FG label modal: remaining qty = in - out - hold. + */ + open fun analyzeQrCode(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { + return analyzeQrCodeInternal(request, availableInOutOnly = false) + } + + /** + * DO workbench label modal: pickable qty = in - out (aligned with workbench scan-pick). + */ + open fun analyzeQrCodeWorkbench(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { + return analyzeQrCodeInternal(request, availableInOutOnly = true) + } + + /** + * DO workbench label modal fallback (no stockInLineId/no-lot row): + * list available lots by item only, using pickable qty = in - out. + */ + open fun listWorkbenchAvailableLotsByItem(itemId: Long): WorkbenchItemLotsResponse { + val source = inventoryLotLineRepository + .findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) + .asSequence() + .filter { !it.deleted && it.inventoryLot?.item != null } + .toList() + val item = source.firstOrNull()?.inventoryLot?.item + ?: throw IllegalStateException("Item not found for itemId=$itemId") + + val sameItemLots = source + .mapNotNull { lotLine -> + val lot = lotLine.inventoryLot ?: return@mapNotNull null + val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null + val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: return@mapNotNull null + val inQty = lotLine.inQty ?: BigDecimal.ZERO + val outQty = lotLine.outQty ?: BigDecimal.ZERO + val remainingQty = inQty.minus(outQty) + if (remainingQty <= BigDecimal.ZERO) return@mapNotNull null SameItemLotInfo( lotNo = lotNo, - inventoryLotLineId = lotLine.id!!, + inventoryLotLineId = lotLine.id ?: return@mapNotNull null, availableQty = remainingQty, uom = uomDesc, - warehouseCode = whCode, - warehouseName = whName + warehouseCode = lotLine.warehouse?.code, + warehouseName = lotLine.warehouse?.name, ) - else null - } - .toList() - - val scannedLotNo = stockInLine.lotNo - ?: stockInLine.inventoryLot?.stockInLine?.lotNo - ?: throw IllegalStateException("Lot number not found for stockInLineId=${request.stockInLineId}") - - return QrCodeAnalysisResponse( - itemId = request.itemId, - itemCode = item.code ?: "", - itemName = item.name ?: "", - scanned = ScannedLotInfo( - stockInLineId = request.stockInLineId, - lotNo = scannedLotNo, - inventoryLotLineId = scannedInventoryLotLine.id - ?: throw IllegalStateException("inventoryLotLineId missing on scanned lot line"), - warehouseCode = scannedInventoryLotLine.warehouse?.code, - warehouseName = scannedInventoryLotLine.warehouse?.name - ), - sameItemLots = sameItemLots - ) -} + } + + return WorkbenchItemLotsResponse( + itemId = item.id ?: itemId, + itemCode = item.code ?: "", + itemName = item.name ?: "", + sameItemLots = sameItemLots, + ) + } + + private fun analyzeQrCodeInternal( + request: QrCodeAnalysisRequest, + availableInOutOnly: Boolean, + ): QrCodeAnalysisResponse { + val stockInLine = stockInLineRepository.findById(request.stockInLineId).orElseThrow() + + val scannedInventoryLotLine = + stockInLine.inventoryLotLine + ?: stockInLine.inventoryLot?.inventoryLotLines?.firstOrNull() + ?: throw IllegalStateException("No inventory lot line found for stockInLineId=${request.stockInLineId}") + + val item = scannedInventoryLotLine.inventoryLot?.item + ?: throw IllegalStateException("Item not found for lot line id=${scannedInventoryLotLine.id}") + + val sameItemLots = inventoryLotLineRepository + .findAllByInventoryLotItemIdAndStatus(request.itemId, InventoryLotLineStatus.AVAILABLE) + .asSequence() + .filter { it.id != scannedInventoryLotLine.id } + .mapNotNull { lotLine -> + val lot = lotLine.inventoryLot ?: return@mapNotNull null + val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null + val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: return@mapNotNull null + val whCode = lotLine.warehouse?.code + val whName = lotLine.warehouse?.name + + val inQty = lotLine.inQty ?: BigDecimal.ZERO + val outQty = lotLine.outQty ?: BigDecimal.ZERO + val holdQty = lotLine.holdQty ?: BigDecimal.ZERO + val remainingQty = + if (availableInOutOnly) inQty.minus(outQty) else inQty.minus(outQty).minus(holdQty) + + if (remainingQty > BigDecimal.ZERO) { + SameItemLotInfo( + lotNo = lotNo, + inventoryLotLineId = lotLine.id!!, + availableQty = remainingQty, + uom = uomDesc, + warehouseCode = whCode, + warehouseName = whName, + ) + } else { + null + } + } + .toList() + + val scannedLotNo = stockInLine.lotNo + ?: stockInLine.inventoryLot?.stockInLine?.lotNo + ?: throw IllegalStateException("Lot number not found for stockInLineId=${request.stockInLineId}") + + return QrCodeAnalysisResponse( + itemId = request.itemId, + itemCode = item.code ?: "", + itemName = item.name ?: "", + scanned = ScannedLotInfo( + stockInLineId = request.stockInLineId, + lotNo = scannedLotNo, + inventoryLotLineId = scannedInventoryLotLine.id + ?: throw IllegalStateException("inventoryLotLineId missing on scanned lot line"), + warehouseCode = scannedInventoryLotLine.warehouse?.code, + warehouseName = scannedInventoryLotLine.warehouse?.name, + ), + sameItemLots = sameItemLots, + ) + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt new file mode 100644 index 0000000..d1af0b6 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt @@ -0,0 +1,702 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.bag.entity.BagRepository +import com.ffii.fpsms.modules.bag.service.BagService +import com.ffii.fpsms.modules.bag.web.model.CreateBagLotLineRequest +import com.ffii.fpsms.modules.common.CodeGenerator +import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecord +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecordRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecord +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRepository +import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus +import com.ffii.fpsms.modules.deliveryOrder.enums.DoPickOrderStatus +import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchScanPickRequest +import com.ffii.fpsms.modules.master.entity.ItemUomRespository +import com.ffii.fpsms.modules.master.service.ItemUomService +import com.ffii.fpsms.modules.master.web.models.MessageResponse +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderLineStatus +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus +import com.ffii.fpsms.modules.pickOrder.service.PickOrderService +import com.ffii.fpsms.modules.stock.entity.Inventory +import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository +import com.ffii.fpsms.modules.stock.entity.StockLedger +import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository +import com.ffii.fpsms.modules.stock.entity.StockOut +import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository +import com.ffii.fpsms.modules.stock.entity.StockOutLine +import com.ffii.fpsms.modules.stock.entity.StockOutRepository +import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository +import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus +import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo +import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus +import com.ffii.fpsms.modules.stock.web.model.StockOutStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import org.springframework.orm.ObjectOptimisticLockingFailureException +data class WorkbenchStockOutLineSummary( + val pickOrderId: Long, + val created: Int, + val reused: Int, +) +@PersistenceContext +private lateinit var entityManager: EntityManager +@Service +open class StockOutLineWorkbenchService( + private val pickOrderRepository: PickOrderRepository, + private val pickOrderLineRepository: PickOrderLineRepository, + private val stockOutRepository: StockOutRepository, + private val stockOutLIneRepository: StockOutLIneRepository, + private val suggestPickLotRepository: SuggestPickLotRepository, + private val inventoryLotLineRepository: InventoryLotLineRepository, + private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, + private val stockLedgerRepository: StockLedgerRepository, + private val itemUomService: ItemUomService, + private val itemUomRespository: ItemUomRespository, + private val bagRepository: BagRepository, + private val bagService: BagService, + private val pickOrderService: PickOrderService, + private val doPickOrderRepository: DoPickOrderRepository, + private val doPickOrderRecordRepository: DoPickOrderRecordRepository, + private val doPickOrderLineRepository: DoPickOrderLineRepository, + private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, + private val deliveryOrderRepository: DeliveryOrderRepository, +) { + + @Transactional(rollbackFor = [Exception::class]) + open fun ensureStockOutLinesForPickOrderNoHold(pickOrderId: Long, userId: Long): WorkbenchStockOutLineSummary { + val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) + ?: return WorkbenchStockOutLineSummary(pickOrderId = pickOrderId, created = 0, reused = 0) + + val consoCode = (pickOrder.consoCode ?: "").trim() + val stockOutKey = if (consoCode.isNotEmpty()) consoCode else "PO-${pickOrder.id ?: 0L}" + val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(stockOutKey) + ?: stockOutRepository.save( + StockOut().apply { + this.consoPickOrderCode = stockOutKey + this.type = pickOrder.type?.value ?: "do" + this.status = StockOutStatus.PENDING.status + this.handler = pickOrder.assignTo?.id ?: userId + } + ) + + val lineIds = pickOrder.pickOrderLines.mapNotNull { it.id } + val suggestions = if (lineIds.isEmpty()) emptyList() else suggestPickLotRepository.findAllByPickOrderLineIdIn(lineIds) + + // Avoid N+1: load existing SOL rows once and check in-memory. + val existingSols = if (lineIds.isEmpty()) emptyList() else stockOutLIneRepository.findAllByPickOrderLineIdInAndDeletedFalse(lineIds) + val existingKeys: Set = existingSols.mapNotNull { sol -> + val polId = sol.pickOrderLine?.id ?: return@mapNotNull null + val illId = sol.inventoryLotLine?.id // may be null + "$polId:${illId ?: "null"}" + }.toSet() + + var created = 0 + var reused = 0 + suggestions.forEach { s -> + val pol = s.pickOrderLine ?: return@forEach + val ill = s.suggestedLotLine + val key = "${pol.id!!}:${ill?.id ?: "null"}" + val exists = existingKeys.contains(key) + if (exists) { + reused++ + return@forEach + } + + val savedSol = stockOutLIneRepository.save( + StockOutLine().apply { + this.stockOut = stockOut + this.pickOrderLine = pol + this.inventoryLotLine = ill + this.item = pol.item + this.status = StockOutLineStatus.PENDING.status + this.qty = 0.0 + this.type = "NOR" + } + ) + suggestedPickLotWorkbenchService.linkSplToStockOutLineWhenSolCreated(savedSol) + created++ + } + + return WorkbenchStockOutLineSummary( + pickOrderId = pickOrderId, + created = created, + reused = reused + ) + } + + /** + * Ensures [StockOutLine] rows exist only for suggestions of the given pick order line (workbench scan-pick fast path). + */ + @Transactional(rollbackFor = [Exception::class]) + open fun ensureStockOutLinesForPickOrderLineNoHold(pickOrderLineId: Long, userId: Long): WorkbenchStockOutLineSummary { + val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) + ?: return WorkbenchStockOutLineSummary(pickOrderId = 0L, created = 0, reused = 0) + val pickOrder = pol.pickOrder + ?: return WorkbenchStockOutLineSummary(pickOrderId = 0L, created = 0, reused = 0) + val pickOrderId = pickOrder.id ?: 0L + + val consoCode = (pickOrder.consoCode ?: "").trim() + val stockOutKey = if (consoCode.isNotEmpty()) consoCode else "PO-${pickOrder.id ?: 0L}" + val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(stockOutKey) + ?: stockOutRepository.save( + StockOut().apply { + this.consoPickOrderCode = stockOutKey + this.type = pickOrder.type?.value ?: "do" + this.status = StockOutStatus.PENDING.status + this.handler = pickOrder.assignTo?.id ?: userId + } + ) + + val suggestions = suggestPickLotRepository.findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId) + + var created = 0 + var reused = 0 + suggestions.forEach { s -> + val polEntity = s.pickOrderLine ?: return@forEach + val ill = s.suggestedLotLine + val exists = if (ill?.id != null) { + stockOutLIneRepository.existsByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse(polEntity.id!!, ill.id!!) + } else { + stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polEntity.id!!).any { it.inventoryLotLineId == null } + } + if (exists) { + reused++ + return@forEach + } + + val savedSol = stockOutLIneRepository.save( + StockOutLine().apply { + this.stockOut = stockOut + this.pickOrderLine = polEntity + this.inventoryLotLine = ill + this.item = polEntity.item + this.status = StockOutLineStatus.PENDING.status + this.qty = 0.0 + this.type = "NOR" + } + ) + suggestedPickLotWorkbenchService.linkSplToStockOutLineWhenSolCreated(savedSol) + created++ + } + + return WorkbenchStockOutLineSummary( + pickOrderId = pickOrderId, + created = created, + reused = reused + ) + } + + @Transactional(rollbackFor = [Exception::class]) + open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse { + val lotNo = request.lotNo.trim() + /* + if (lotNo.isEmpty()) { + return MessageResponse( + id = null, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "lotNo is required", errorPosition = null, entity = null + ) + } +*/ + val sol = stockOutLIneRepository.findById(request.stockOutLineId).orElse(null) + ?: return MessageResponse( + id = request.stockOutLineId, name = "scan_pick", code = "NOT_FOUND", type = "workbench_scan_pick", + message = "Stock out line not found", errorPosition = null, entity = null + ) + + val st = sol.status?.trim()?.lowercase() ?: "" + if (st == "completed" || st == StockOutLineStatus.COMPLETE.status.lowercase()) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Stock out line already completed", errorPosition = null, entity = null + ) + } + if (st == "rejected" || st == StockOutLineStatus.REJECTED.status.lowercase()) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Stock out line is rejected", errorPosition = null, entity = null + ) + } + + val pol = sol.pickOrderLine + ?: return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Pick order line is missing", errorPosition = null, entity = null + ) + val item = pol.item + ?: return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Item is missing on pick order line", errorPosition = null, entity = null + ) + val itemId = item.id!! + val polId = pol.id!! + + // ✅ Workbench "zero-complete": explicit qty=0 means finish this SOL without inventory posting. + // Used by Just Completed when user overrides qty to 0. + val explicitQty = request.qty?.coerceAtLeast(BigDecimal.ZERO) + if (explicitQty != null && explicitQty.compareTo(BigDecimal.ZERO) == 0) { + sol.status = StockOutLineStatus.COMPLETE.status + sol.pickTime = LocalDateTime.now() + sol.handledBy = request.userId + if (sol.startTime == null) sol.startTime = LocalDateTime.now() + sol.endTime = LocalDateTime.now() + stockOutLIneRepository.saveAndFlush(sol) + + // Ensure POL/PO completion is re-evaluated even when delta=0 (no inventory posting). + try { + + checkWorkbenchPickOrderLineCompleted(polId) + } catch (e: Exception) { + println("⚠️ Workbench zero-complete POL refresh: ${e.message}") + } + /* + val consoCode = sol.pickOrderLine?.pickOrder?.consoCode + if (!consoCode.isNullOrBlank()) { + try { + pickOrderService.checkAndCompletePickOrderByConsoCode(consoCode) + } catch (e: Exception) { + println("⚠️ Workbench zero-complete pick order completion check: ${e.message}") + } + } +*/ + val mapped = stockOutLIneRepository.findStockOutLineInfoById(sol.id!!) + return MessageResponse( + id = sol.id, + name = lotNo, + code = "SUCCESS", + type = "workbench_scan_pick", + message = "Workbench SOL completed with qty=0 (no inventory posting)", + errorPosition = null, + entity = mapped, + ) + } + + val scannedIll = inventoryLotLineRepository + .findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc(lotNo, itemId) + ?: return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Lot not found for this item: $lotNo", errorPosition = null, entity = null + ) + + if (scannedIll.deleted) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Lot line is deleted", errorPosition = null, entity = null + ) + } + + if (scannedIll.status != InventoryLotLineStatus.AVAILABLE) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Lot line is not available (status=${scannedIll.status})", errorPosition = null, entity = null + ) + } + val expiry = scannedIll.inventoryLot?.expiryDate + if (expiry != null && expiry.isBefore(LocalDate.now())) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Lot is expired (expiry=$expiry)", errorPosition = null, entity = null + ) + } + if (scannedIll.inventoryLot?.item?.id != itemId) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Scanned lot does not match pick line item", errorPosition = null, entity = null + ) + } + + sol.inventoryLotLine = scannedIll + + val required = pol.qty ?: BigDecimal.ZERO + val infos = stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) + val endedSumOthers = infos + .filter { WorkbenchStockOutLinePickProgress.isCountedAsPicked(it) && it.id != sol.id } + .fold(BigDecimal.ZERO) { acc, i -> acc + i.qty } + val currentQtyBd = BigDecimal(sol.qty?.toString() ?: "0") + val remainingPol = required.subtract(endedSumOthers).subtract(currentQtyBd).coerceAtLeast(BigDecimal.ZERO) + + val splForLot = suggestPickLotRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) + .firstOrNull { it.suggestedLotLine?.id == scannedIll.id } + val chunkTarget = when { + splForLot?.qty != null && splForLot.qty!! > BigDecimal.ZERO -> + splForLot.qty!!.min(remainingPol.add(currentQtyBd)) + else -> remainingPol.add(currentQtyBd) + } + val stillNeedOnThisSol = chunkTarget.subtract(currentQtyBd).coerceAtLeast(BigDecimal.ZERO) + if (stillNeedOnThisSol <= BigDecimal.ZERO) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "No remaining quantity to pick on this stock out line", errorPosition = null, entity = null + ) + } + + // Align with DoWorkbenchMainService.scanPick: explicit qty may exceed this SOL chunk; cap by lot availability only. + val requestedDelta = when { + request.qty != null -> request.qty!!.coerceAtLeast(BigDecimal.ZERO) + else -> stillNeedOnThisSol + } + if (requestedDelta < BigDecimal.ZERO) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick", + message = "Pick quantity must be positive", errorPosition = null, entity = null + ) + } + + val illId = scannedIll.id!! + val freshIll = inventoryLotLineRepository.findById(illId).orElse(scannedIll) + val availablePickable = (freshIll.inQty ?: BigDecimal.ZERO) + .subtract(freshIll.outQty ?: BigDecimal.ZERO) + val plannedDelta = requestedDelta.min(availablePickable) + if (plannedDelta <= BigDecimal.ZERO) { + return MessageResponse( + id = sol.id, name = "scan_pick", code = "CONFLICT", type = "workbench_scan_pick", + message = "Insufficient available quantity on lot (may have been picked by another user)", + errorPosition = null, entity = null + ) + } + + // val expectedVersion = freshIll.version ?: 0 + // val updated = inventoryLotLineRepository.incrementOutQtyIfAvailable(illId, plannedDelta, expectedVersion) + val maxRetries = 2 +var attempt = 0 +var updated = 0 +var finalPlannedDelta = plannedDelta +while (attempt <= maxRetries) { + val latestIll = inventoryLotLineRepository.findById(illId).orElse(scannedIll) + val latestAvailable = (latestIll.inQty ?: BigDecimal.ZERO) + .subtract(latestIll.outQty ?: BigDecimal.ZERO) + // 用最新庫存重算這次可扣數 + finalPlannedDelta = requestedDelta.min(latestAvailable) + if (finalPlannedDelta <= BigDecimal.ZERO) { + // 這時才回不足(不是版本衝突) + return MessageResponse( + id = sol.id, name = "scan_pick", code = "CONFLICT", type = "workbench_scan_pick", + message = "Insufficient available quantity on lot (may have been picked by another user)", + errorPosition = null, entity = null + ) + } + val expectedVersion = latestIll.version ?: 0 + updated = inventoryLotLineRepository.incrementOutQtyIfAvailable(illId, finalPlannedDelta, expectedVersion) + if (updated > 0) break + attempt++ + if (attempt <= maxRetries) { + Thread.sleep(15L) // optional: 小 backoff,避免連續撞鎖 + } +} +if (updated == 0) { + // 重試用盡才回衝突(不在第一次 updated==0 就回) + return MessageResponse( + id = sol.id, name = "scan_pick", code = "CONFLICT", type = "workbench_scan_pick", + message = "庫存或版本已變更,請重掃或重算(Concurrent pick or stock changed)", + errorPosition = null, entity = null + ) +} + + val newQtyBd = currentQtyBd.add(finalPlannedDelta) + sol.qty = newQtyBd.toDouble() + val doneThisRow = newQtyBd.compareTo(chunkTarget) >= 0 + sol.status = if (doneThisRow) StockOutLineStatus.COMPLETE.status else StockOutLineStatus.PARTIALLY_COMPLETE.status + sol.pickTime = LocalDateTime.now() + sol.handledBy = request.userId + if (sol.startTime == null) sol.startTime = LocalDateTime.now() + if (doneThisRow) sol.endTime = LocalDateTime.now() + stockOutLIneRepository.saveAndFlush(sol) + + postWorkbenchPickSideEffects(sol.id!!, finalPlannedDelta) + + val pickOrderId = pol.pickOrder?.id + if (pickOrderId != null) { + suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(pickOrderId) + ensureStockOutLinesForPickOrderNoHold(pickOrderId, request.userId) + } + + val mapped = stockOutLIneRepository.findStockOutLineInfoById(sol.id!!) + return MessageResponse( + id = sol.id, + name = scannedIll.inventoryLot?.lotNo ?: lotNo, + code = "SUCCESS", + type = "workbench_scan_pick", + message = "Workbench pick posted", + errorPosition = null, + entity = mapped, + ) + } + + /** + * Workbench-only: after atomic inventory lot update — ledger, bag, pick order line / pick order / DO sync. + */ + private fun postWorkbenchPickSideEffects(stockOutLineId: Long, deltaQty: BigDecimal) { + if (deltaQty <= BigDecimal.ZERO) return + val savedStockOutLine = stockOutLIneRepository.findById(stockOutLineId).orElseThrow() + createWorkbenchPickLedger(stockOutLineId, deltaQty) + try { + val solItem = savedStockOutLine.item + val inventoryLotLine = savedStockOutLine.inventoryLotLine + val reqDeltaQty = deltaQty.toDouble() + val statusLower = savedStockOutLine.status?.trim()?.lowercase() ?: "" + val isCompletedOrPartiallyCompleted = + statusLower == "completed" || + statusLower == "partially_completed" || + statusLower == StockOutLineStatus.PARTIALLY_COMPLETE.status.lowercase() + if (solItem?.isBag == true && + inventoryLotLine != null && + isCompletedOrPartiallyCompleted && + reqDeltaQty > 0 + ) { + val bag = bagRepository.findByItemIdAndDeletedIsFalse(solItem.id!!) + if (bag != null) { + val lotNo = inventoryLotLine.inventoryLot?.lotNo + if (lotNo != null) { + bagService.createBagLotLinesByBagId( + CreateBagLotLineRequest( + bagId = bag.id!!, + lotId = inventoryLotLine.inventoryLot?.id ?: 0L, + itemId = solItem.id!!, + lotNo = lotNo, + stockQty = reqDeltaQty.toInt(), + date = LocalDate.now(), + time = LocalTime.now(), + stockOutLineId = savedStockOutLine.id + ) + ) + } + } + } + } catch (e: Exception) { + println("Workbench bag side effect: ${e.message}") + e.printStackTrace() + } + val pol = savedStockOutLine.pickOrderLine ?: return + val polId = pol.id ?: return + + checkWorkbenchPickOrderLineCompleted(polId) + val pickOrder = pol.pickOrder + val poId = pickOrder?.id + if (poId != null) { + val allLines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(poId) + val allCompleted = allLines.isNotEmpty() && allLines.all { + it.status == PickOrderLineStatus.COMPLETED + } + if (allCompleted) { + completePickOrderWithRetry(poId) + completeDoForPickOrderWorkbench(poId) + completeDoIfAllPickOrdersCompletedWorkbench(poId) + } + } + + } + + private fun createWorkbenchPickLedger(stockOutLineId: Long, deltaQty: BigDecimal) { + if (deltaQty <= BigDecimal.ZERO) return + val sol = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return + val solItem = sol.item ?: return + val inventory = itemUomService.findInventoryForItemBaseUom(solItem.id!!) ?: return + // Fast path: use inventory onHand after update + delta to reconstruct previous balance. + val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() + val previousBalance = onHandAfter + deltaQty.toDouble() + val newBalance = previousBalance - deltaQty.toDouble() + val ledger = StockLedger().apply { + this.stockOutLine = sol + this.inventory = inventory + this.inQty = null + this.outQty = deltaQty.toDouble() + this.balance = newBalance + this.type = "NOR" + this.itemId = solItem.id + this.itemCode = solItem.code + this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id + ?: inventory.uom?.id + this.date = LocalDate.now() + } + stockLedgerRepository.save(ledger) + } + + private fun resolveWorkbenchPreviousBalance( + itemId: Long, + inventory: Inventory, + onHandQtyBeforeUpdate: Double?, + ): Double { + if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate + val latestLedger = stockLedgerRepository.findLatestByItemId(itemId).firstOrNull() + return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() + } + + private fun isWorkbenchSolEndStatus(status: String?): Boolean { + val s = status?.trim()?.lowercase() ?: return false + return s == "completed" || + s == StockOutLineStatus.COMPLETE.status.lowercase() || + s == "rejected" || + s == StockOutLineStatus.REJECTED.status.lowercase() || + s == "partially_completed" || + s == StockOutLineStatus.PARTIALLY_COMPLETE.status.lowercase() + } + + + /** Mirrors [com.ffii.fpsms.modules.stock.service.StockOutLineService.checkIsStockOutLineCompleted] for workbench-only use. */ + private fun checkWorkbenchPickOrderLineCompleted(pickOrderLineId: Long) { + val allStockOutLines = stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId) + val pickOrderLine = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) + val requiredQty = pickOrderLine?.qty ?: BigDecimal.ZERO + val unfinishedLine = allStockOutLines.filter { + val rawStatus = it.status.trim() + val status = rawStatus.lowercase() + val isComplete = + status == "completed" || status == StockOutLineStatus.COMPLETE.status.lowercase() + val isRejected = + status == "rejected" || status == StockOutLineStatus.REJECTED.status.lowercase() + val isPartiallyComplete = + status == "partially_completed" || status == StockOutLineStatus.PARTIALLY_COMPLETE.status.lowercase() + !(isComplete || isRejected || isPartiallyComplete) + } + if (unfinishedLine.isEmpty()) { + val pol = pickOrderLineRepository.findById(pickOrderLineId).orElseThrow() + pickOrderLineRepository.save( + pol.apply { this.status = PickOrderLineStatus.COMPLETED } + ) + } + } + + private fun completeDoForPickOrderWorkbench(pickOrderId: Long) { + val dpos = doPickOrderRepository.findByPickOrderId(pickOrderId) + if (dpos.isNotEmpty()) { + dpos.forEach { + it.ticketStatus = DoPickOrderStatus.completed + it.ticketCompleteDateTime = LocalDateTime.now() + } + doPickOrderRepository.saveAll(dpos) + dpos.forEach { dpo -> + dpo.doOrderId?.let { doId -> + val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId) + if (deliveryOrder != null && deliveryOrder.status != DeliveryOrderStatus.COMPLETED) { + deliveryOrder.status = DeliveryOrderStatus.COMPLETED + deliveryOrderRepository.save(deliveryOrder) + } + } + } + } + val dporList = doPickOrderRecordRepository.findByPickOrderId(pickOrderId) + if (dporList.isNotEmpty()) { + dporList.forEach { + it.ticketStatus = DoPickOrderStatus.completed + it.ticketCompleteDateTime = LocalDateTime.now() + } + doPickOrderRecordRepository.saveAll(dporList) + } + } + + private fun completeDoIfAllPickOrdersCompletedWorkbench(pickOrderId: Long) { + val lines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(pickOrderId) + val doPickOrderIds = + if (lines.isNotEmpty()) lines.mapNotNull { it.doPickOrderId }.distinct() + else doPickOrderRepository.findByPickOrderId(pickOrderId).mapNotNull { it.id } + + doPickOrderIds.forEach { dpoId -> + val allLines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(dpoId) + val allPickOrderIdsInDpo = if (allLines.isNotEmpty()) { + allLines.mapNotNull { it.pickOrderId }.distinct() + } else { + doPickOrderRepository.findById(dpoId).orElse(null)?.pickOrderId?.let { listOf(it) } ?: emptyList() + } + if (allPickOrderIdsInDpo.isEmpty()) return@forEach + + val statuses = allPickOrderIdsInDpo.map { id -> + pickOrderRepository.findById(id).orElse(null)?.status + } + val allCompleted = statuses.all { it == PickOrderStatus.COMPLETED } + if (!allCompleted) return@forEach + + val dpo = doPickOrderRepository.findById(dpoId).orElse(null) ?: return@forEach + dpo.ticketStatus = DoPickOrderStatus.completed + dpo.ticketCompleteDateTime = LocalDateTime.now() + doPickOrderRepository.save(dpo) + + val prefix = "DN" + val midfix = CodeGenerator.DEFAULT_MIDFIX + val latestCode = doPickOrderRecordRepository.findLatestDeliveryNoteCodeByPrefix("${prefix}-${midfix}") + val deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode) + + val dpoRecord = DoPickOrderRecord( + recordId = dpoId, + storeId = dpo.storeId ?: "", + ticketNo = dpo.ticketNo ?: "", + ticketStatus = DoPickOrderStatus.completed, + truckId = dpo.truckId, + pickOrderId = dpo.pickOrderId, + truckDepartureTime = dpo.truckDepartureTime, + shopId = dpo.shopId, + handledBy = dpo.handledBy, + handlerName = dpo.handlerName, + doOrderId = dpo.doOrderId, + pickOrderCode = dpo.pickOrderCode, + deliveryOrderCode = dpo.deliveryOrderCode, + deliveryNoteCode = deliveryNoteCode, + loadingSequence = dpo.loadingSequence, + ticketReleaseTime = dpo.ticketReleaseTime, + ticketCompleteDateTime = LocalDateTime.now(), + truckLanceCode = dpo.truckLanceCode, + shopCode = dpo.shopCode, + shopName = dpo.shopName, + requiredDeliveryDate = dpo.requiredDeliveryDate + ) + val savedHeader = doPickOrderRecordRepository.save(dpoRecord) + + val lineRecords = allLines.map { l -> + DoPickOrderLineRecord().apply { + this.recordId = l.id + this.doPickOrderId = savedHeader.recordId + this.pickOrderId = l.pickOrderId + this.doOrderId = l.doOrderId + this.pickOrderCode = l.pickOrderCode + this.deliveryOrderCode = l.deliveryOrderCode + this.status = l.status + } + } + if (lineRecords.isNotEmpty()) doPickOrderLineRecordRepository.saveAll(lineRecords) + if (allLines.isNotEmpty()) doPickOrderLineRepository.deleteAll(allLines) + doPickOrderRepository.delete(dpo) + + dpo.doOrderId?.let { doId -> + val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId) + if (deliveryOrder != null && deliveryOrder.status != DeliveryOrderStatus.COMPLETED) { + deliveryOrder.status = DeliveryOrderStatus.COMPLETED + deliveryOrderRepository.save(deliveryOrder) + } + } + } + } + fun completePickOrderWithRetry(poId: Long, maxAttempts: Int = 3) { + var last: Exception? = null + + repeat(maxAttempts) { attempt -> + try { + // 建议:每次重试前 clear,避免拿到旧实体 + entityManager.clear() + + val po = pickOrderRepository.findById(poId).orElse(null) ?: return + if (po.status == PickOrderStatus.COMPLETED) return // ✅ 幂等:已经完成就当成功 + + po.status = PickOrderStatus.COMPLETED + po.completeDate = LocalDateTime.now() + + pickOrderRepository.saveAndFlush(po) // 这里可能抛 optimistic lock + return + } catch (e: ObjectOptimisticLockingFailureException) { + last = e + // 小退避,避免立刻再次冲突 + Thread.sleep((10L..50L).random()) + } + } + + throw last ?: RuntimeException("Failed to complete pick order after retries") + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt new file mode 100644 index 0000000..92f0754 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt @@ -0,0 +1,636 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository +import com.ffii.fpsms.modules.stock.entity.InventoryLotLine +import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository +import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository +import com.ffii.fpsms.modules.stock.entity.SuggestedPickLot +import com.ffii.fpsms.modules.stock.entity.StockOutLine +import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository +import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus +import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus +import com.ffii.fpsms.modules.stock.enums.SuggestedPickLotType +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.time.LocalDate + +data class WorkbenchSuggestionSummary( + val pickOrderId: Long, + val created: Int, + val skippedExisting: Int, +) + +@Service +open class SuggestedPickLotWorkbenchService( + private val pickOrderLineRepository: PickOrderLineRepository, + private val inventoryLotLineRepository: InventoryLotLineRepository, + private val stockOutLIneRepository: StockOutLIneRepository, + private val suggestPickLotRepository: SuggestPickLotRepository, +) { + + private val log = LoggerFactory.getLogger(SuggestedPickLotWorkbenchService::class.java) + + /** + * Historical SPL rows: linked to a [StockOutLine] that is already **completed** — never soft-deleted or + * repointed by rebuild (已揀 segment). Remaining work uses non-frozen SPL rows only. + */ + private fun isSplFrozen(spl: SuggestedPickLot): Boolean { + val sol = spl.stockOutLine ?: return false + val st = sol.status?.trim()?.lowercase() ?: return false + return st == "completed" || st.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true) + } + + /** + * After [StockOutLine] is created from workbench ensure, bind 1:1 SPL ↔ SOL (剩餘 / pending segment). + */ + @Transactional(rollbackFor = [Exception::class]) + open fun linkSplToStockOutLineWhenSolCreated(sol: StockOutLine) { + val pol = sol.pickOrderLine ?: return + val polId = pol.id ?: return + val illId = sol.inventoryLotLine?.id + val candidates = suggestPickLotRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) + .asSequence() + .filter { !isSplFrozen(it) } + .filter { + val sid = it.suggestedLotLine?.id + when { + illId == null -> sid == null + else -> sid == illId + } + } + .toList() + val spl = candidates.filter { it.stockOutLine == null }.maxByOrNull { it.id ?: 0L } + ?: candidates.maxByOrNull { it.id ?: 0L } + ?: return + spl.stockOutLine = sol + suggestPickLotRepository.save(spl) + } + + /** + * After workbench scan-pick updates [StockOutLine] qty/status, bind SPL and set **qty** to match SOL (已揀 + 進行中). + */ + @Transactional(rollbackFor = [Exception::class]) + open fun linkSplToStockOutLineAfterWorkbenchPick(stockOutLineId: Long) { + val sol = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return + val pol = sol.pickOrderLine ?: return + val polId = pol.id ?: return + val ill = sol.inventoryLotLine + val illId = ill?.id + val qtyBd = BigDecimal.valueOf(sol.qty ?: 0.0) + + var spl = suggestPickLotRepository.findFirstByStockOutLineId(stockOutLineId) + if (spl == null) { + spl = suggestPickLotRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) + .asSequence() + .filter { !isSplFrozen(it) } + .filter { + val sid = it.suggestedLotLine?.id + when { + illId == null -> sid == null + else -> sid == illId + } + } + .maxByOrNull { it.id ?: 0L } + } + if (spl != null) { + spl.stockOutLine = sol + spl.suggestedLotLine = ill + spl.pickOrderLine = pol + spl.qty = qtyBd + spl.type = SuggestedPickLotType.PICK_ORDER + spl.deleted = false + suggestPickLotRepository.saveAndFlush(spl) + } else { + suggestPickLotRepository.save( + SuggestedPickLot().apply { + type = SuggestedPickLotType.PICK_ORDER + stockOutLine = sol + suggestedLotLine = ill + pickOrderLine = pol + qty = qtyBd + }, + ) + } + } + + /** + * Backend-default avoid warehouse list for workbench suggestions. + * - Case-insensitive match against `warehouse.code`. + * - When request.excludeWarehouseCodes is provided, it overrides this default. + * + * Keep it empty if you don't need any default excludes. + */ + private val defaultExcludeWarehouseCodes: Set = setOf( + "2F-W202-01-00", + "2F-W200-#A-00", +) + private fun normalizeStoreId(raw: String?): String? { + val s = raw?.trim()?.uppercase() ?: return null + if (s.isEmpty()) return null + return s.replace("/", "").replace(" ", "") + } + + private fun isWarehouseExcluded(lot: InventoryLotLine, excludeWarehouseCodes: Set): Boolean { + if (excludeWarehouseCodes.isEmpty()) return false + val code = lot.warehouse?.code?.trim()?.uppercase() + return !code.isNullOrEmpty() && code in excludeWarehouseCodes + } + + private fun effectiveExcludeWarehouseCodes(raw: List?): Set { + if (raw == null) return defaultExcludeWarehouseCodes + val normalized = raw + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { it.uppercase() } + .toSet() + return normalized + } + + /** + * Build suggestions with no-hold rule: + * available = inQty - outQty (ignore holdQty). + * When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id). + * When multiple segments, matches by [suggestedLotLineId], inserts gaps, soft-deletes obsolete rows. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun rebuildNoHoldSuggestionsForPickOrder( + pickOrderId: Long, + excludeWarehouseCodes: List? = null, + ): WorkbenchSuggestionSummary { + stockOutLIneRepository.flush() + val lines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrderId) + if (lines.isEmpty()) { + return WorkbenchSuggestionSummary(pickOrderId = pickOrderId, created = 0, skippedExisting = 0) + } + + val lineIds = lines.mapNotNull { it.id } + val alreadyPickedByLineId: Map = if (lineIds.isEmpty()) { + emptyMap() + } else { + // Avoid N+1: load all SOL infos once (projection), aggregate picked qty per pick_order_line. + val solInfos = stockOutLIneRepository.findAllInfoByPickOrderLineIdInAndDeletedFalse(lineIds) + solInfos.groupBy { it.pickOrderLineId ?: 0L } + .filterKeys { it != 0L } + .mapValues { (_, sols) -> WorkbenchStockOutLinePickProgress.sumPickedQty(sols) } + } + + val itemIds = lines.mapNotNull { it.item?.id }.distinct() + val today = LocalDate.now() + val excludeSet = effectiveExcludeWarehouseCodes(excludeWarehouseCodes) + val lotsByItem = inventoryLotLineRepository.findAllByItemIdIn(itemIds) + .filter { !it.deleted } + .filter { it.status == InventoryLotLineStatus.AVAILABLE } + .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } + .filter { !isWarehouseExcluded(it, excludeSet) } + .sortedBy { it.inventoryLot?.expiryDate } + .groupBy { it.inventoryLot?.item?.id } + + var created = 0 + var skipped = 0 + lines.forEach { line -> + val lineId = line.id ?: return@forEach + val required = line.qty ?: BigDecimal.ZERO + if (required <= BigDecimal.ZERO) { + softDeleteAllSuggestionsForLine(lineId) + skipped++ + return@forEach + } + + val alreadyPicked = alreadyPickedByLineId[lineId] ?: BigDecimal.ZERO + val remaining = required.subtract(alreadyPicked) + if (remaining <= BigDecimal.ZERO) { + softDeleteAllSuggestionsForLine(lineId) + skipped++ + return@forEach + } + + val lots = lotsByItem[line.item?.id].orEmpty() + val desired = buildDesiredAllocations(lots, remaining, lineId) + created += syncSuggestedPickLotsForLine(line, desired) + } + + return WorkbenchSuggestionSummary( + pickOrderId = pickOrderId, + created = created, + skippedExisting = skipped, + ) + } + + /** + * Workbench assign-time "priming" mode: + * - Do NOT pre-split across multiple lots. + * - For each pick_order_line, create/update exactly ONE SPL row pointing to the next available lot + * with qty = remaining (required - already picked). + * + * Intended for workbench ticket assignment so scan-pick can drive subsequent re-suggest/splits. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun primeNextSingleLotSuggestionsForPickOrder( + pickOrderId: Long, + storeId: String? = null, + excludeWarehouseCodes: List? = null, + ): WorkbenchSuggestionSummary { + stockOutLIneRepository.flush() + val lines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrderId) + if (lines.isEmpty()) { + return WorkbenchSuggestionSummary(pickOrderId = pickOrderId, created = 0, skippedExisting = 0) + } + + val lineIds = lines.mapNotNull { it.id } + val alreadyPickedByLineId: Map = if (lineIds.isEmpty()) { + emptyMap() + } else { + val solInfos = stockOutLIneRepository.findAllInfoByPickOrderLineIdInAndDeletedFalse(lineIds) + solInfos.groupBy { it.pickOrderLineId ?: 0L } + .filterKeys { it != 0L } + .mapValues { (_, sols) -> WorkbenchStockOutLinePickProgress.sumPickedQty(sols) } + } + + var created = 0 + var skipped = 0 + lines.forEach { line -> + val lineId = line.id ?: return@forEach + val required = line.qty ?: BigDecimal.ZERO + if (required <= BigDecimal.ZERO) { + softDeleteAllSuggestionsForLine(lineId) + skipped++ + return@forEach + } + + val alreadyPicked = alreadyPickedByLineId[lineId] ?: BigDecimal.ZERO + val remaining = required.subtract(alreadyPicked) + if (remaining <= BigDecimal.ZERO) { + softDeleteAllSuggestionsForLine(lineId) + skipped++ + return@forEach + } + + val r = setNoHoldSuggestionsForPickOrderLineNextSingleLot( + pickOrderLineId = lineId, + targetQty = remaining, + storeId = storeId, + excludeInventoryLotLineId = null, + excludeWarehouseCodes = excludeWarehouseCodes, + ) + created += r.created + } + + return WorkbenchSuggestionSummary(pickOrderId = pickOrderId, created = created, skippedExisting = skipped) + } + + /** + * Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId]. + * Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun rebuildNoHoldSuggestionsForPickOrderLine( + pickOrderLineId: Long, + storeId: String? = null, + excludeWarehouseCodes: List? = null, + ): WorkbenchSuggestionSummary { + stockOutLIneRepository.flush() + val line = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) + ?: return WorkbenchSuggestionSummary(pickOrderId = 0L, created = 0, skippedExisting = 0) + if (line.deleted) { + return WorkbenchSuggestionSummary( + pickOrderId = line.pickOrder?.id ?: 0L, + created = 0, + skippedExisting = 0, + ) + } + val pickOrderId = line.pickOrder?.id ?: 0L + + val lineFresh = pickOrderLineRepository.findById(pickOrderLineId).orElse(line) + val required = lineFresh.qty ?: BigDecimal.ZERO + if (required <= BigDecimal.ZERO) { + softDeleteAllSuggestionsForLine(pickOrderLineId) + return WorkbenchSuggestionSummary(pickOrderId = pickOrderId, created = 0, skippedExisting = 1) + } + + val solInfos = stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId) + val solSnapshot = solInfos.joinToString("; ") { info -> + "sol${info.id} st=${info.status} qty=${info.qty} picked=${WorkbenchStockOutLinePickProgress.isCountedAsPicked(info)}" + } + val alreadyPicked = WorkbenchStockOutLinePickProgress.sumPickedQty(solInfos) + val remaining = required.subtract(alreadyPicked) + log.info( + "WORKBENCH_SPL_TRACE polLine={} poId={} polRequired={} alreadyPicked={} remainingIntoAllocate={} sols=[{}]", + pickOrderLineId, + pickOrderId, + required, + alreadyPicked, + remaining, + solSnapshot, + ) + if (remaining <= BigDecimal.ZERO) { + softDeleteAllSuggestionsForLine(pickOrderLineId) + return WorkbenchSuggestionSummary(pickOrderId = pickOrderId, created = 0, skippedExisting = 1) + } + + val itemId = line.item?.id + ?: return WorkbenchSuggestionSummary(pickOrderId = pickOrderId, created = 0, skippedExisting = 0) + val today = LocalDate.now() + val storeKey = normalizeStoreId(storeId) + val excludeSet = effectiveExcludeWarehouseCodes(excludeWarehouseCodes) + val lots = inventoryLotLineRepository.findAllByItemIdIn(listOf(itemId)) + .filter { !it.deleted } + .filter { it.status == InventoryLotLineStatus.AVAILABLE } + .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } + .filter { !isWarehouseExcluded(it, excludeSet) } + .filter { lot -> + if (storeKey == null) true + else normalizeStoreId(lot.warehouse?.store_id) == storeKey + } + .sortedBy { it.inventoryLot?.expiryDate } + + val desired = buildDesiredAllocations(lots, remaining, pickOrderLineId) + val created = syncSuggestedPickLotsForLine(lineFresh, desired) + + val sumSplQty = desired.fold(BigDecimal.ZERO) { acc, p -> acc.add(p.second) } + log.info( + "WORKBENCH_SPL_TRACE polLine={} poId={} desiredRows={} sumDesiredQty={} newInserts={} detail=[{}]", + pickOrderLineId, + pickOrderId, + desired.size, + sumSplQty, + created, + desired.joinToString("; ") { (ill, q) -> + val illId = ill?.id + val ln = ill?.inventoryLot?.lotNo ?: "no-lot" + "qty=$q illId=$illId lotNo=$ln" + }, + ) + + return WorkbenchSuggestionSummary( + pickOrderId = pickOrderId, + created = created, + skippedExisting = 0, + ) + } + + /** + * Workbench explicit-qty remainder support (single-next-lot): + * Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot. + * When [storeId] is provided, the next lot must be within the same store (warehouse.store_id). + * If no eligible lot exists, create a single "no-lot tail" row (suggestedLotLine = null) for visibility. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun setNoHoldSuggestionsForPickOrderLineNextSingleLot( + pickOrderLineId: Long, + targetQty: BigDecimal, + storeId: String? = null, + excludeInventoryLotLineId: Long? = null, + excludeWarehouseCodes: List? = null, + ): WorkbenchSuggestionSummary { + stockOutLIneRepository.flush() + val line = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) + ?: return WorkbenchSuggestionSummary(pickOrderId = 0L, created = 0, skippedExisting = 0) + if (line.deleted) { + return WorkbenchSuggestionSummary( + pickOrderId = line.pickOrder?.id ?: 0L, + created = 0, + skippedExisting = 0, + ) + } + val pickOrderId = line.pickOrder?.id ?: 0L + if (targetQty <= BigDecimal.ZERO) { + softDeleteAllSuggestionsForLine(pickOrderLineId) + return WorkbenchSuggestionSummary(pickOrderId = pickOrderId, created = 0, skippedExisting = 1) + } + val itemId = line.item?.id + ?: return WorkbenchSuggestionSummary(pickOrderId = pickOrderId, created = 0, skippedExisting = 0) + val today = LocalDate.now() + val storeKey = normalizeStoreId(storeId) + val excludeSet = effectiveExcludeWarehouseCodes(excludeWarehouseCodes) + val nextLot = inventoryLotLineRepository.findAllByItemIdIn(listOf(itemId)) + .asSequence() + .filter { !it.deleted } + .filter { it.status == InventoryLotLineStatus.AVAILABLE } + .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } + .filter { !isWarehouseExcluded(it, excludeSet) } + .filter { lot -> + if (excludeInventoryLotLineId == null) true else lot.id != excludeInventoryLotLineId + } + .filter { lot -> + if (storeKey == null) true + else normalizeStoreId(lot.warehouse?.store_id) == storeKey + } + .sortedBy { it.inventoryLot?.expiryDate } + .firstOrNull() + + val desired = listOf(nextLot to targetQty) + val created = syncSuggestedPickLotsForLine(line, desired) + return WorkbenchSuggestionSummary( + pickOrderId = pickOrderId, + created = created, + skippedExisting = 0, + ) + } + + /** + * Workbench explicit-qty split support: + * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). + * - If [targetQty] <= 0: soft-delete all active suggestions for the line. + * - Else: allocate across available lots (no-hold) and upsert SPL rows to match the allocation. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun setNoHoldSuggestionsForPickOrderLineExactQty( + pickOrderLineId: Long, + targetQty: BigDecimal, + excludeWarehouseCodes: List? = null, + ): WorkbenchSuggestionSummary { + stockOutLIneRepository.flush() + val line = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) + ?: return WorkbenchSuggestionSummary(pickOrderId = 0L, created = 0, skippedExisting = 0) + if (line.deleted) { + return WorkbenchSuggestionSummary( + pickOrderId = line.pickOrder?.id ?: 0L, + created = 0, + skippedExisting = 0, + ) + } + val pickOrderId = line.pickOrder?.id ?: 0L + if (targetQty <= BigDecimal.ZERO) { + softDeleteAllSuggestionsForLine(pickOrderLineId) + return WorkbenchSuggestionSummary(pickOrderId = pickOrderId, created = 0, skippedExisting = 1) + } + val itemId = line.item?.id + ?: return WorkbenchSuggestionSummary(pickOrderId = pickOrderId, created = 0, skippedExisting = 0) + val today = LocalDate.now() + val excludeSet = effectiveExcludeWarehouseCodes(excludeWarehouseCodes) + val lots = inventoryLotLineRepository.findAllByItemIdIn(listOf(itemId)) + .filter { !it.deleted } + .filter { it.status == InventoryLotLineStatus.AVAILABLE } + .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } + .filter { !isWarehouseExcluded(it, excludeSet) } + .sortedBy { it.inventoryLot?.expiryDate } + + val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId) + val created = syncSuggestedPickLotsForLine(line, desired) + return WorkbenchSuggestionSummary(pickOrderId = pickOrderId, created = created, skippedExisting = 0) + } + + private fun softDeleteAllSuggestionsForLine(pickOrderLineId: Long) { + val active = suggestPickLotRepository.findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId) + val toClear = active.filter { !isSplFrozen(it) } + if (toClear.isEmpty()) return + toClear.forEach { it.deleted = true } + suggestPickLotRepository.saveAllAndFlush(toClear) + } + + /** Desired (lot line or null for no-lot tail, qty) segments; never exceeds [remainingInit]. */ + private fun buildDesiredAllocations( + lots: List, + remainingInit: BigDecimal, + pickOrderLineIdForLog: Long, + ): List> { + val out = mutableListOf>() + var remaining = remainingInit + for (lot in lots) { + if (remaining <= BigDecimal.ZERO) break + val available = (lot.inQty ?: BigDecimal.ZERO).subtract(lot.outQty ?: BigDecimal.ZERO) + if (available <= BigDecimal.ZERO) continue + val assign = available.min(remaining) + if (assign > BigDecimal.ZERO) { + log.info( + "WORKBENCH_SPL_ALLOC polLine={} illId={} lotNo={} avail={} assign={} remainingAfter={}", + pickOrderLineIdForLog, + lot.id, + lot.inventoryLot?.lotNo, + available, + assign, + remaining.subtract(assign), + ) + out += lot to assign + remaining = remaining.subtract(assign) + } + } + if (remaining > BigDecimal.ZERO) { + log.info( + "WORKBENCH_SPL_ALLOC polLine={} illId=null lotNo=no-lot avail=n/a assign={} remainingAfter=0 (no-lot tail)", + pickOrderLineIdForLog, + remaining, + ) + out += null to remaining + } + return out + } + + /** + * Collapse duplicate SPL rows per [suggestedLotLineId]; soft-delete extras. Returns one row per lot key. + */ + private fun dedupeKeepersBySuggestedLotLine(existing: List): List { + val byKey = existing.groupBy { it.suggestedLotLine?.id } + val keepers = mutableListOf() + val dupSoftDelete = mutableListOf() + for ((_, group) in byKey) { + if (group.size == 1) { + keepers.add(group.first()) + } else { + val keeper = group.maxBy { it.id ?: 0L } + keepers.add(keeper) + group.filter { it.id != keeper.id }.forEach { dup -> + dup.deleted = true + dupSoftDelete.add(dup) + } + } + } + if (dupSoftDelete.isNotEmpty()) { + suggestPickLotRepository.saveAllAndFlush(dupSoftDelete) + } + return keepers + } + + /** + * Align DB rows with [desired]. Returns count of newly inserted SPL rows. + */ + private fun syncSuggestedPickLotsForLine( + line: PickOrderLine, + desired: List>, + ): Int { + val polId = line.id!! + val existing = suggestPickLotRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) + val mutableExisting = existing.filter { !isSplFrozen(it) } + val keepers = dedupeKeepersBySuggestedLotLine(mutableExisting) + + // Single allocation segment: repoint a **non-frozen** canonical row — frozen historical SPLs stay untouched. + if (desired.size == 1) { + val (ill, qty) = desired.first() + val toPersist = mutableListOf() + val reuse = keepers.maxByOrNull { it.id ?: 0L } + if (reuse != null) { + reuse.suggestedLotLine = ill + reuse.pickOrderLine = line + reuse.type = SuggestedPickLotType.PICK_ORDER + reuse.qty = qty + reuse.deleted = false + toPersist.add(reuse) + for (spl in keepers) { + if (spl.id != reuse.id) { + spl.deleted = true + toPersist.add(spl) + } + } + } else { + toPersist.add( + SuggestedPickLot().apply { + type = SuggestedPickLotType.PICK_ORDER + suggestedLotLine = ill + pickOrderLine = line + this.qty = qty + }, + ) + } + if (toPersist.isNotEmpty()) { + suggestPickLotRepository.saveAllAndFlush(toPersist) + } + return if (reuse == null) 1 else 0 + } + + val matchedIds = mutableSetOf() + val toPersist = mutableListOf() + var newInsertCount = 0 + val byIllKey = keepers.associateBy { it.suggestedLotLine?.id }.toMutableMap() + + for ((ill, qty) in desired) { + val k = ill?.id + val spl = byIllKey[k] + if (spl != null) { + spl.suggestedLotLine = ill + spl.pickOrderLine = line + spl.type = SuggestedPickLotType.PICK_ORDER + spl.qty = qty + spl.deleted = false + spl.id?.let { matchedIds.add(it) } + toPersist.add(spl) + byIllKey.remove(k) + } else { + newInsertCount++ + toPersist.add( + SuggestedPickLot().apply { + type = SuggestedPickLotType.PICK_ORDER + suggestedLotLine = ill + pickOrderLine = line + this.qty = qty + }, + ) + } + } + + for (spl in keepers) { + val id = spl.id ?: continue + if (id !in matchedIds) { + spl.deleted = true + toPersist.add(spl) + } + } + + if (toPersist.isNotEmpty()) { + suggestPickLotRepository.saveAllAndFlush(toPersist) + } + return newInsertCount + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/WorkbenchStockOutLinePickProgress.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/WorkbenchStockOutLinePickProgress.kt new file mode 100644 index 0000000..b293bbe --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/WorkbenchStockOutLinePickProgress.kt @@ -0,0 +1,31 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo +import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus +import java.math.BigDecimal + +/** + * Workbench / DO scan-pick: which [StockOutLineInfo] rows count as already consuming + * [pick_order_line] quantity (must stay in sync for scan validation and SPL rebuild). + * + * FG legacy may leave SOL as [StockOutLineStatus.CHECKED]; omitting it causes double-pick and SPL sums > POL qty. + */ +object WorkbenchStockOutLinePickProgress { + fun isCountedAsPicked(info: StockOutLineInfo): Boolean { + val q = info.qty + val s = info.status.trim().lowercase() + // Legacy / mapping gaps: qty posted but status blank — still consumes POL remainder (avoids double pick). + if (q > BigDecimal.ZERO && s.isEmpty()) return true + if (s.isEmpty()) return false + return s == "completed" || + s == "partially_completed" || + s == "checked" || + (s == "rejected" && info.qty > BigDecimal.ZERO) || + s.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true) || + s.equals(StockOutLineStatus.PARTIALLY_COMPLETE.status, ignoreCase = true) || + s.equals(StockOutLineStatus.CHECKED.status, ignoreCase = true) + } + + fun sumPickedQty(infos: List): BigDecimal = + infos.filter { isCountedAsPicked(it) }.fold(BigDecimal.ZERO) { acc, i -> acc.add(i.qty) } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt index 7cf521f..865d87e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt @@ -27,6 +27,7 @@ import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineReque import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineStatusRequest import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse +import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse import org.springframework.http.HttpStatus import org.springframework.web.server.ResponseStatusException @@ -117,4 +118,16 @@ class InventoryLotLineController ( fun analyzeQrCode(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { return inventoryLotLineService.analyzeQrCode(request) } + + /** DO workbench label modal: same-item lots use pickable qty inQty - outQty (no hold). */ + @PostMapping("/workbench/analyze-qr-code") + fun analyzeQrCodeWorkbench(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { + return inventoryLotLineService.analyzeQrCodeWorkbench(request) + } + + /** Workbench label modal fallback: load same-item available lots without stockInLineId. */ + @GetMapping("/workbench/available-lots-by-item/{itemId}") + fun listWorkbenchAvailableLotsByItem(@PathVariable itemId: Long): WorkbenchItemLotsResponse { + return inventoryLotLineService.listWorkbenchAvailableLotsByItem(itemId) + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt index f904aa5..b54b240 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt @@ -17,7 +17,7 @@ data class UpdateInventoryLotLineStatusRequest( ) data class QrCodeAnalysisRequest( val itemId: Long, - val stockInLineId: Long + val stockInLineId: Long, ) data class ScannedLotInfo( @@ -43,4 +43,11 @@ data class QrCodeAnalysisResponse( val itemName: String, val scanned: ScannedLotInfo, val sameItemLots: List +) + +data class WorkbenchItemLotsResponse( + val itemId: Long, + val itemCode: String, + val itemName: String, + val sameItemLots: List ) \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260408_01_Enson/01_alter_stock_take.sql b/src/main/resources/db/changelog/changes/20260408_01_Enson/01_alter_stock_take.sql new file mode 100644 index 0000000..2bad205 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260408_01_Enson/01_alter_stock_take.sql @@ -0,0 +1,68 @@ +-- liquibase formatted sql +-- changeset Enson:alter_pick_order_add_deliveryOrderPickOrderId +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='fpsmsdb' AND table_name='pick_order' AND column_name='deliveryOrderPickOrderId' +ALTER TABLE `fpsmsdb`.`pick_order` +Add COLUMN `deliveryOrderPickOrderId` INT; +-- changeset Enson:create_delivery_order_pick_order +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='fpsmsdb' AND table_name='delivery_order_pick_order' +Create Table `fpsmsdb`.`delivery_order_pick_order` ( + `id` INT NOT NULL AUTO_INCREMENT, + `truckId` INT NULL, + `shopId` INT NULL, + `storeId` VARCHAR(10) NULL, + `requiredDeliveryDate` DATE NULL, + `truckDepartureTime` TIME NULL, + `truckLanceCode` VARCHAR(50) NULL, + `shopCode` VARCHAR(50) NULL, + `shopName` VARCHAR(255) NULL, + `loadingSequence` INT NULL, + `ticketNo` VARCHAR(50) NULL, + `ticketReleaseTime` DATETIME NULL, + `ticketCompleteDateTime` DATETIME NULL, + `ticketStatus` ENUM('pending','released','completed') NOT NULL DEFAULT 'pending', + `releaseType` VARCHAR(100) NULL, + `handledBy` INT NULL, + `handlerName` VARCHAR(100) NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL, + `version` INT NOT NULL DEFAULT 0, + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_dopo_store_date_status` (`storeId`, `requiredDeliveryDate`, `ticketStatus`, `deleted`), + INDEX `idx_dopo_truck_time` (`truckLanceCode`, `truckDepartureTime`, `requiredDeliveryDate`), + INDEX `idx_dopo_shop_date` (`shopId`, `requiredDeliveryDate`, `deleted`), + UNIQUE KEY `uk_dopo_ticket_no` (`ticketNo`) +); +-- changeset Enson:create_job_order_matching +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='fpsmsdb' AND table_name='job_order_matching' +Create Table `fpsmsdb`.`job_order_matching` ( + `id` INT NOT NULL AUTO_INCREMENT, + `pickOrderId` INT NOT NULL, + `jobOrderId` INT NOT NULL, + `itemId` INT NOT NULL, + `itemCode` VARCHAR(255) NOT NULL, + `itemName` VARCHAR(255) NOT NULL, + `status` VARCHAR(255) NOT NULL, + `matchingDate` DATETIME NOT NULL, + `matchingBy` INT NOT NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` INT NOT NULL, + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `modifiedBy` INT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_jom_pickOrder_deleted` (`pickOrderId`, `deleted`), + INDEX `idx_jom_job_deleted` (`jobOrderId`, `deleted`), + INDEX `idx_jom_item_status_date` (`itemId`, `status`, `matchingDate`), + INDEX `idx_jom_matchingBy_date` (`matchingBy`, `matchingDate`), + FOREIGN KEY (`pickOrderId`) REFERENCES `fpsmsdb`.`pick_order` (`id`), + FOREIGN KEY (`jobOrderId`) REFERENCES `fpsmsdb`.`job_order` (`id`) +); + + + diff --git a/src/main/resources/db/changelog/changes/20260417_01_Enson/01_alter_stock_take.sql b/src/main/resources/db/changelog/changes/20260417_01_Enson/01_alter_stock_take.sql new file mode 100644 index 0000000..5c83ae7 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260417_01_Enson/01_alter_stock_take.sql @@ -0,0 +1,9 @@ +-- liquibase formatted sql +-- changeset Enson:alter_delivery_order_pick_order +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='fpsmsdb' AND table_name='delivery_order_pick_order' AND column_name='deliveryNoteCode' +ALTER TABLE `fpsmsdb`.`delivery_order_pick_order` +Add COLUMN `deliveryNoteCode` VARCHAR(255), +Add COLUMN `cartonQty` INT; + + diff --git a/src/main/resources/db/changelog/changes/20260420_01_Enson/03_alter_stock_take.sql b/src/main/resources/db/changelog/changes/20260420_01_Enson/03_alter_stock_take.sql new file mode 100644 index 0000000..05c52a2 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260420_01_Enson/03_alter_stock_take.sql @@ -0,0 +1,5 @@ +--liquibase formatted sql + +--changeset Enson:20260422-04 +CREATE INDEX idx_po_dopo_assign_deleted +ON fpsmsdb.pick_order (deliveryOrderPickOrderId, assignTo, deleted); \ No newline at end of file From c68bdda3661ea683f6e89bab311c1e31852ee76a Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Sun, 26 Apr 2026 00:23:56 +0800 Subject: [PATCH 02/12] update job order list part , can use record part again. and updated but not yet finish consuble pick order --- .../modules/master/service/ItemsService.kt | 8 +- .../service/PickOrderWorkbenchService.kt | 82 ++++++++++++++----- .../pickOrder/web/PickOrderController.kt | 18 ++++ .../web/models/ConsoPickOrderResponse.kt | 1 + 4 files changed, 84 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt index 4a91d2a..72afd92 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt @@ -269,7 +269,7 @@ open class ItemsService( open fun getPickOrderItemsByPage(args: Map): List> { try { println("=== Debug: getPickOrderItemsByPage in ItemsService ===") - println("Args: $args") + //println("Args: $args") val sql = StringBuilder( """ @@ -333,11 +333,11 @@ open class ItemsService( sql.append(" ORDER BY po.targetDate DESC, i.name ASC ") val finalSql = sql.toString() - println("Final SQL: $finalSql") - println("SQL args: $args") + // println("Final SQL: $finalSql") + //println("SQL args: $args") val result = jdbcDao.queryForList(finalSql, args) - println("Query result size: ${result.size}") + //println("Query result size: ${result.size}") result.forEach { row -> println("Result row: $row") } return result } catch (e: Exception) { diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt index 46906bf..2bedb42 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt @@ -50,7 +50,7 @@ open class PickOrderWorkbenchService( /** * Workbench assign V2 for consumable pick orders: * - Assign selected pick orders to user and mark ASSIGNED. - * - Build no-hold suggestions + ensure stock_out_line for each pick order. + * - Do NOT create suggestion/stock_out_line here (created on Tab3 line select). */ @Transactional(rollbackFor = [Exception::class]) open fun assignPickOrdersV2(pickOrderIds: List, assignTo: Long): MessageResponse { @@ -73,34 +73,17 @@ open class PickOrderWorkbenchService( pickOrder.status = PickOrderStatus.ASSIGNED } pickOrderRepository.saveAll(pickOrders) - - val assignedIds = mutableListOf() - var suggestionRows = 0 - var stockOutLines = 0 - pickOrders.forEach { po -> - val poId = po.id ?: return@forEach - val suggestSummary = suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( - pickOrderId = poId, - storeId = null, - excludeWarehouseCodes = null, - ) - val solSummary = stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, assignTo) - assignedIds.add(poId) - suggestionRows += suggestSummary.created - stockOutLines += solSummary.created - } + val assignedIds = pickOrders.mapNotNull { it.id } return MessageResponse( id = null, name = "Workbench assign v2 success", code = "SUCCESS", type = "pickorder_workbench", - message = "Assigned and initialized no-hold suggestion/stock-out for workbench", + message = "Assigned pick orders for workbench (no suggestion/stock-out at assign stage)", errorPosition = null, entity = mapOf( - "pickOrderIds" to assignedIds, - "suggestionRowsCreated" to suggestionRows, - "stockOutLinesCreated" to stockOutLines + "pickOrderIds" to assignedIds ) ) } catch (e: Exception) { @@ -342,6 +325,7 @@ open class PickOrderWorkbenchService( lotNo = r["lotNo"]?.toString(), expiryDate = toLocalDate(r["expiryDate"]), location = r["location"]?.toString(), + itemId = pol.item?.id, stockUnit = uomDesc, availableQty = availableQty, requiredQty = toBigDecimal(r["suggestedQty"]) ?: pol.qty ?: zero, @@ -379,6 +363,7 @@ open class PickOrderWorkbenchService( lotNo = null, expiryDate = null, location = null, + itemId = pol.item?.id, stockUnit = uomDesc, availableQty = null, requiredQty = pol.qty ?: zero, @@ -400,5 +385,60 @@ open class PickOrderWorkbenchService( return withLot + noLot } + + /** + * Workbench suggest V2 for consumable pick orders (first-time suggestion path): + * - Create/rebuild no-hold suggestions for this pick order. + * - Ensure stock_out_line rows exist for created/reused suggestions. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun suggestPickOrderV2(pickOrderId: Long, userId: Long): MessageResponse { + return try { + val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) + ?: return MessageResponse( + id = pickOrderId, + name = "Workbench suggest v2 failed", + code = "ERROR", + type = "pickorder_workbench", + message = "Pick order not found", + errorPosition = null + ) + + val suggestSummary = suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( + pickOrderId = pickOrderId, + storeId = null, + excludeWarehouseCodes = null, + ) + val stockOutSummary = stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold( + pickOrderId = pickOrderId, + userId = userId, + ) + + MessageResponse( + id = pickOrder.id, + name = "Workbench suggest v2 success", + code = "SUCCESS", + type = "pickorder_workbench", + message = "Workbench suggestion prepared", + errorPosition = null, + entity = mapOf( + "pickOrderId" to pickOrderId, + "suggestionRowsCreated" to suggestSummary.created, + "suggestionRowsSkippedExisting" to suggestSummary.skippedExisting, + "stockOutLinesCreated" to stockOutSummary.created, + "stockOutLinesReused" to stockOutSummary.reused, + ) + ) + } catch (e: Exception) { + MessageResponse( + id = pickOrderId, + name = "Workbench suggest v2 failed", + code = "ERROR", + type = "pickorder_workbench", + message = "Failed to prepare workbench suggestion: ${e.message}", + errorPosition = null + ) + } + } } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt index ded41ce..c352f09 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt @@ -105,6 +105,24 @@ class PickOrderController( return pickOrderWorkbenchService.getWorkbenchPickOrderLineLotDetailsV2(pickOrderLineId) } + @PostMapping("/workbench/suggest-v2/{pickOrderId}") + fun suggestPickOrderWorkbenchV2( + @PathVariable pickOrderId: Long, + @RequestBody request: Map, + ): MessageResponse { + val userId = request["userId"]?.toString()?.toLongOrNull() + ?: request["assignTo"]?.toString()?.toLongOrNull() + ?: return MessageResponse( + id = pickOrderId, + name = "Workbench suggest v2 failed", + code = "ERROR", + type = "pickorder_workbench", + message = "userId is required", + errorPosition = null + ) + return pickOrderWorkbenchService.suggestPickOrderV2(pickOrderId, userId) + } + // Release Pick Orders (without consoCode) @PostMapping("/release") fun releasePickOrders(@RequestBody request: Map): MessageResponse { diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ConsoPickOrderResponse.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ConsoPickOrderResponse.kt index 05e7cd6..b4a3f0e 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ConsoPickOrderResponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ConsoPickOrderResponse.kt @@ -162,6 +162,7 @@ data class PickOrderLineLotDetailResponse( val lotNo: String?, val expiryDate: LocalDate?, val location: String?, + val itemId: Long? = null, val stockUnit: String?, val availableQty: BigDecimal?, val requiredQty: BigDecimal?, From 140d86bed9de18378671366e84294f9b176aea0e Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Sun, 26 Apr 2026 13:30:30 +0800 Subject: [PATCH 03/12] update consumable print label model input --- .../java/com/ffii/fpsms/modules/master/service/ItemsService.kt | 2 +- .../ffii/fpsms/modules/pickOrder/service/PickOrderService.kt | 2 ++ .../modules/pickOrder/service/PickOrderWorkbenchService.kt | 3 +++ .../modules/pickOrder/web/models/ConsoPickOrderResponse.kt | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt index 72afd92..d2fbc98 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt @@ -338,7 +338,7 @@ open class ItemsService( val result = jdbcDao.queryForList(finalSql, args) //println("Query result size: ${result.size}") - result.forEach { row -> println("Result row: $row") } + // result.forEach { row -> println("Result row: $row") } return result } catch (e: Exception) { println("Error in getPickOrderItemsByPage: ${e.message}") diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt index 7cba195..5ecdecc 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt @@ -943,6 +943,7 @@ open class PickOrderService( lotNo = il?.lotNo, expiryDate = il?.expiryDate, location = w?.code, + stockInLineId = il?.stockInLine?.id, stockUnit = ill.stockUom?.uom?.udfudesc ?: uomDesc, availableQty = availableQty, requiredQty = spl?.qty ?: zero, @@ -974,6 +975,7 @@ open class PickOrderService( lotNo = null, expiryDate = null, location = null, + stockInLineId = null, stockUnit = uomDesc, availableQty = null, requiredQty = zero, diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt index 2bedb42..aafe0ed 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt @@ -252,6 +252,7 @@ open class PickOrderWorkbenchService( val lotSql = """ SELECT ill.id AS lotId, + il.stockInLineId AS stockInLineId, il.lotNo AS lotNo, il.expiryDate AS expiryDate, w.code AS location, @@ -326,6 +327,7 @@ open class PickOrderWorkbenchService( expiryDate = toLocalDate(r["expiryDate"]), location = r["location"]?.toString(), itemId = pol.item?.id, + stockInLineId = toLong(r["stockInLineId"]), stockUnit = uomDesc, availableQty = availableQty, requiredQty = toBigDecimal(r["suggestedQty"]) ?: pol.qty ?: zero, @@ -364,6 +366,7 @@ open class PickOrderWorkbenchService( expiryDate = null, location = null, itemId = pol.item?.id, + stockInLineId = null, stockUnit = uomDesc, availableQty = null, requiredQty = pol.qty ?: zero, diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ConsoPickOrderResponse.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ConsoPickOrderResponse.kt index b4a3f0e..2d7f095 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ConsoPickOrderResponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ConsoPickOrderResponse.kt @@ -163,6 +163,7 @@ data class PickOrderLineLotDetailResponse( val expiryDate: LocalDate?, val location: String?, val itemId: Long? = null, + val stockInLineId: Long? = null, val stockUnit: String?, val availableQty: BigDecimal?, val requiredQty: BigDecimal?, From fb9969913a9101ddf0b90dee81bce2fba2b041da Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Sun, 26 Apr 2026 15:25:39 +0800 Subject: [PATCH 04/12] update reutrn message --- .../modules/deliveryOrder/service/DoWorkbenchMainService.kt | 4 ++-- .../modules/stock/service/StockOutLineWorkbenchService.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 7178b22..dd4e5df 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -718,9 +718,9 @@ return MessageResponse( code = "SUCCESS", type = "workbench_scan_pick", message = if (effectiveLotExhaustedSplit) { - "Workbench pick posted; remaining quantity allocated to next stock-out line" + " remaining quantity allocated to next stock-out line" } else { - "Workbench pick posted" + "pick successful" }, errorPosition = null, entity = mapped, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt index d1af0b6..5c3b449 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt @@ -426,7 +426,7 @@ if (updated == 0) { name = scannedIll.inventoryLot?.lotNo ?: lotNo, code = "SUCCESS", type = "workbench_scan_pick", - message = "Workbench pick posted", + message = "pick successful", errorPosition = null, entity = mapped, ) From 4fc26f1c70dfff6950460666f601c092835ca15c Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Sun, 26 Apr 2026 21:34:15 +0800 Subject: [PATCH 05/12] update --- .../modules/pickOrder/service/PickOrderWorkbenchService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt index aafe0ed..9d9c0e5 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt @@ -422,7 +422,7 @@ open class PickOrderWorkbenchService( name = "Workbench suggest v2 success", code = "SUCCESS", type = "pickorder_workbench", - message = "Workbench suggestion prepared", + message = "Suggestion success", errorPosition = null, entity = mapOf( "pickOrderId" to pickOrderId, From 8bdc74ef90b0e3461f239b191a9ee3bc43e84987 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Sun, 26 Apr 2026 22:05:51 +0800 Subject: [PATCH 06/12] hide some print --- .../service/DoWorkbenchMainService.kt | 28 +++++++------------ .../stock/service/StockOutLineService.kt | 6 ++-- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index dd4e5df..0a12eb1 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -475,6 +475,7 @@ open class DoWorkbenchMainService( val solSnapshot = infos.joinToString("; ") { info -> "sol${info.id} st=${info.status} qty=${info.qty} picked=${WorkbenchStockOutLinePickProgress.isCountedAsPicked(info)}" } +/* log.info( "WORKBENCH_SCAN_TRACE polId={} solId={} scanLotNo={} scanIllId={} polRequired={} [{}] endedSumOthers={} currentSolQty={} remainingPol={} splMatchQty={} chunkTarget={} stillNeedOnThisSol={} requestedCap={} availScanLot={} plannedDelta={} lotSplit={}", polId, @@ -494,7 +495,7 @@ log.info( plannedDelta, isLotExhaustedSplit, // initial trace only ) - +*/ val prepMs = lapMs() // retry-related state @@ -533,26 +534,16 @@ while (attempt <= maxRetries) { "SELECT id, inQty, outQty, status, version FROM inventory_lot_line WHERE id = ?", illId ) - log.info( - "WB_SQL_TRACE phase=before traceId={} connId={} illId={} inQty={} outQty={} status={} ver={} delta={} expVer={} retryAttempt={}", - traceId, connIdBefore, illId, - beforeRow["inQty"], beforeRow["outQty"], beforeRow["status"], beforeRow["version"], - effectiveDelta, expectedVersion, attempt - ) + updated = inventoryLotLineRepository.incrementOutQtyIfAvailable(illId, effectiveDelta, expectedVersion) - println("updated: $updated") + // println("updated: $updated") val connIdAfter = jdbcTemplate.queryForObject("SELECT CONNECTION_ID()", Long::class.java) val afterRow = jdbcTemplate.queryForMap( "SELECT id, inQty, outQty, status, version FROM inventory_lot_line WHERE id = ?", illId ) - log.info( - "WB_SQL_TRACE phase=after traceId={} connId={} illId={} updated={} inQty={} outQty={} status={} ver={} retryAttempt={}", - traceId, connIdAfter, illId, updated, - afterRow["inQty"], afterRow["outQty"], afterRow["status"], afterRow["version"], attempt - ) if (updated > 0) break @@ -695,6 +686,7 @@ val mapped = stockOutLIneRepository.findStockOutLineInfoById(sol.id!!) val mapFetchMs = (System.nanoTime() - mapFetchT0) / 1_000_000 val totalMs = (System.nanoTime() - wall0) / 1_000_000 +/* log.info( "workbench scanPick timing (ms): total={} prep={} outbound={} saveSol={} rebuildSpl={} ensureSol={} polPartial={} postEffects={} mapFetch={} lotSplit={} solId={} polId={} poId={}", totalMs, @@ -711,7 +703,7 @@ log.info( polId, pickOrderId ?: 0L, ) - +*/ return MessageResponse( id = sol.id, name = scannedIll.inventoryLot?.lotNo ?: lotNo, @@ -1699,8 +1691,8 @@ return MessageResponse( "pickOrders" to emptyList(), ) } - println(" Found delivery_order_pick_order ID: $doPickOrderId") - println(" delivery_order_pick_order ticketStatus: $doTicketStatus") + //println(" Found delivery_order_pick_order ID: $doPickOrderId") + //println(" delivery_order_pick_order ticketStatus: $doTicketStatus") if (doTicketStatus == "completed") { return mapOf( @@ -1736,7 +1728,7 @@ return MessageResponse( ) } mark("query.pickOrdersMs", pickOrdersQueryMs) - println(" Found ${pickOrdersInfo.size} pick orders (workbench)") + // println(" Found ${pickOrdersInfo.size} pick orders (workbench)") var payload: Map = emptyMap() val assembleMs = measureTimeMillis { @@ -1889,7 +1881,7 @@ return MessageResponse( return emptyList() } - println(" Found ${results.size} active FG workbench pick orders for user: $userId") + // println(" Found ${results.size} active FG workbench pick orders for user: $userId") return results.map { row -> val pickOrderIdsStr = row["pickOrderIds"] as? String ?: "" diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index d68ad34..5ca3c88 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -1804,7 +1804,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { if (flushAfterSave) { stockLedgerRepository.saveAndFlush(ledger) - println("${tracePrefix}Ledger created successfully for stockOutLineId=$solId") + //println("${tracePrefix}Ledger created successfully for stockOutLineId=$solId") } else { stockLedgerRepository.save(ledger) } @@ -1847,12 +1847,12 @@ open fun batchScan(request: com.ffii.fpsms.modules.stock.web.model.BatchScanRequ try { // 1) Bulk load all pick order lines val pickOrderLineIds = request.lines.map { it.pickOrderLineId }.distinct() - println("Loading ${pickOrderLineIds.size} pick order lines...") + //println("Loading ${pickOrderLineIds.size} pick order lines...") val pickOrderLines = pickOrderLineRepository.findAllById(pickOrderLineIds).associateBy { it.id } // 2) Bulk load all inventory lot lines (if any) val lotLineIds = request.lines.mapNotNull { it.inventoryLotLineId }.distinct() - println("Loading ${lotLineIds.size} inventory lot lines...") + // println("Loading ${lotLineIds.size} inventory lot lines...") val inventoryLotLines = if (lotLineIds.isNotEmpty()) { inventoryLotLineRepository.findAllById(lotLineIds).associateBy { it.id } } else { From a0dd5a53c1c40c121b21863972a588602d8fea2e Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 27 Apr 2026 11:48:31 +0800 Subject: [PATCH 07/12] update workbenchprintlabel to show QRcode --- .../ffii/fpsms/modules/stock/service/InventoryLotLineService.kt | 2 ++ .../java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt | 1 + 2 files changed, 3 insertions(+) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index 5515b69..718e95d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -454,6 +454,7 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit SameItemLotInfo( lotNo = lotNo, inventoryLotLineId = lotLine.id ?: return@mapNotNull null, + stockInLineId = lot.stockInLine?.id, availableQty = remainingQty, uom = uomDesc, warehouseCode = lotLine.warehouse?.code, @@ -504,6 +505,7 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit SameItemLotInfo( lotNo = lotNo, inventoryLotLineId = lotLine.id!!, + stockInLineId = lot.stockInLine?.id, availableQty = remainingQty, uom = uomDesc, warehouseCode = whCode, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt index b54b240..0d5d450 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt @@ -31,6 +31,7 @@ data class ScannedLotInfo( data class SameItemLotInfo( val lotNo: String, val inventoryLotLineId: Long, + val stockInLineId: Long? = null, val availableQty: BigDecimal, val uom: String, val warehouseCode: String? = null, From 92b2e35117a1d4b125f1cf185f29cf238cc59d8f Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 27 Apr 2026 14:15:22 +0800 Subject: [PATCH 08/12] update expiry lot batch handle --- .../service/PickExecutionIssueService.kt | 58 ++++----- .../entity/InventoryLotLineRepository.kt | 14 +++ .../stock/service/StockOutLineService.kt | 115 ++++++++++++++++++ .../stock/web/model/SaveStockOutRequest.kt | 16 +++ 4 files changed, 172 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index 0fe2094..c38d793 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -42,13 +42,15 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine import com.ffii.fpsms.modules.master.entity.ItemsRepository import com.ffii.fpsms.modules.master.entity.ItemUomRespository import com.ffii.fpsms.modules.common.CodeGenerator - +import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult import com.ffii.fpsms.modules.stock.entity.StockLedger import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository import org.springframework.beans.factory.annotation.Value +import com.ffii.fpsms.modules.stock.web.model.BatchStockOutRequest +import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest @Service open class PickExecutionIssueService( private val pickExecutionIssueRepository: PickExecutionIssueRepository, @@ -2202,54 +2204,48 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse { open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse { try { val lotLines = inventoryLotLineRepository.findAllById(request.lotLineIds) - .filter { + .filter { val lot = it.inventoryLot val today = LocalDate.now() - lot?.expiryDate != null && lot.expiryDate!!.isBefore(today) && + lot?.expiryDate != null && + lot.expiryDate!!.isBefore(today) && (it.inQty ?: BigDecimal.ZERO) != (it.outQty ?: BigDecimal.ZERO) } - + if (lotLines.isEmpty()) { return MessageResponse( - id = null, - name = "Error", - code = "EMPTY", - type = "stock_issue", - message = "No valid expiry items to submit", - errorPosition = null + id = null, name = "Error", code = "EMPTY", type = "stock_issue", + message = "No valid expiry items to submit", errorPosition = null ) } - + val handler = request.handler ?: SecurityUtils.getUser().orElse(null)?.id ?: 0L - - lotLines.forEach { lotLine -> - val remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO) - - stockOutLineService.createStockOut( - StockOutRequest( - inventoryLotLineId = lotLine.id!!, - qty = remainingQty.toDouble(), - type = "Expiry" + val batchReq = BatchStockOutRequest( + type = "Expiry", + handler = handler, + lines = lotLines.map { ll -> + val remaining = (ll.inQty ?: BigDecimal.ZERO).subtract(ll.outQty ?: BigDecimal.ZERO) + BatchStockOutLineRequest( + inventoryLotLineId = ll.id!!, + qty = remaining.toDouble() ) - ) - } - + } + ) + + val result = stockOutLineService.createStockOutBatch(batchReq) + return MessageResponse( - id = null, + id = result.stockOutId, name = "Success", code = "SUCCESS", type = "stock_issue", - message = "Successfully submitted ${lotLines.size} expiry item(s)", + message = "Successfully submitted ${result.processedCount} expiry item(s), skipped ${result.skippedCount}", errorPosition = null ) } catch (e: Exception) { return MessageResponse( - id = null, - name = "Error", - code = "ERROR", - type = "stock_issue", - message = "Failed to submit expiry items: ${e.message}", - errorPosition = null + id = null, name = "Error", code = "ERROR", type = "stock_issue", + message = "Failed to submit expiry items: ${e.message}", errorPosition = null ) } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 6c453cf..d82a0e5 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -262,4 +262,18 @@ WHERE ill.id = :id """ ) fun countByStatusAndDeletedIsFalse(@Param("status") status: InventoryLotLineStatus): Long + + @EntityGraph( + type = EntityGraph.EntityGraphType.FETCH, + attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"] +) +@Query(""" + SELECT ill + FROM InventoryLotLine ill + WHERE ill.id IN :ids + AND ill.deleted = false +""") +fun findAllByIdInAndDeletedFalseWithRefs( + @Param("ids") ids: Collection +): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 5ca3c88..f1c43b9 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -2223,4 +2223,119 @@ fun applyStockOutLineDelta( return savedSol } +@Transactional(rollbackFor = [Exception::class]) +open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult { + if (request.lines.isEmpty()) return BatchStockOutResult(null, 0, 0) + + val currentUserId = request.handler ?: SecurityUtils.getUser().orElseThrow().id ?: 0L + val lotLineIds = request.lines.map { it.inventoryLotLineId }.distinct() + + // 1) 一次 preload lotLine + item + warehouse + val lotLines = inventoryLotLineRepository + .findAllByIdInAndDeletedFalseWithRefs(lotLineIds) + + val lotLineMap = lotLines.associateBy { it.id!! } + + // 2) 构造 header(每批一个,避免每笔新建) + val stockOutHeader = StockOut().apply { + this.type = request.type + this.completeDate = LocalDateTime.now() + this.handler = currentUserId + this.status = StockOutStatus.COMPLETE.status + } + val savedHeader = stockOutRepository.save(stockOutHeader) + + val now = LocalDateTime.now() + val today = LocalDate.now() + + val lotLinesToUpdate = mutableListOf() + val stockOutLinesToInsert = mutableListOf() + val ledgersToInsert = mutableListOf() + var skipped = 0 + + // 3) 内存校验 + 组装对象(不在循环里 findById / saveAndFlush) + request.lines.forEach { lineReq -> + val lotLine = lotLineMap[lineReq.inventoryLotLineId] + if (lotLine == null) { + skipped++ + return@forEach + } + + val qtyBd = BigDecimal.valueOf(lineReq.qty) + if (qtyBd <= BigDecimal.ZERO) { + skipped++ + return@forEach + } + + // 可用量校验(按你现有规则) + val inQty = lotLine.inQty ?: BigDecimal.ZERO + val outQty = lotLine.outQty ?: BigDecimal.ZERO + val holdQty = lotLine.holdQty ?: BigDecimal.ZERO + val available = inQty.subtract(outQty) // Expiry 通常吃剩余量,可视业务改成 -holdQty + if (qtyBd > available) { + skipped++ + return@forEach + } + + // 更新 lot line + lotLine.outQty = outQty.add(qtyBd) + lotLine.holdQty = holdQty.subtract(qtyBd).coerceAtLeast(BigDecimal.ZERO) + lotLine.status = inventoryLotLineService.deriveInventoryLotLineStatus( + lotLine.status, lotLine.inQty, lotLine.outQty, lotLine.holdQty + ) + lotLinesToUpdate += lotLine + + val item = lotLine.inventoryLot?.item ?: run { + skipped++ + return@forEach + } + + // 组装 stock_out_line + val sol = StockOutLine().apply { + this.item = item + this.qty = lineReq.qty + this.stockOut = savedHeader + this.inventoryLotLine = lotLine + this.status = StockOutLineStatus.COMPLETE.status + this.pickTime = now + this.handledBy = currentUserId + this.type = request.type + } + stockOutLinesToInsert += sol + } + + // 4) 批量写(关键) + inventoryLotLineRepository.saveAll(lotLinesToUpdate) + val savedLines = stockOutLineRepository.saveAll(stockOutLinesToInsert) + + // 5) 批量组装 ledger(避免每笔 createStockLedgerForStockOut) + savedLines.forEach { sol -> + val item = sol.item ?: return@forEach + val inv = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return@forEach + val delta = BigDecimal.valueOf(sol.qty ?: 0.0) + val prevBalance = inv.onHandQty?.toDouble() ?: 0.0 +val newBalance = prevBalance - delta.toDouble() + + ledgersToInsert += StockLedger().apply { + this.stockOutLine = sol + this.inventory = inv + this.inQty = null + this.outQty = delta.toDouble() + this.balance = newBalance + this.type = request.type + this.itemId = item.id + this.itemCode = item.code + this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id + ?: inv.uom?.id + this.date = today + } + } + stockLedgerRepository.saveAll(ledgersToInsert) + + return BatchStockOutResult( + stockOutId = savedHeader.id, + processedCount = savedLines.size, + skippedCount = skipped + ) +} } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt index b8aa0eb..2841f08 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt @@ -102,3 +102,19 @@ data class BatchScanRequest( val userId: Long, val lines: List ) +data class BatchStockOutRequest( + val type: String, // "Expiry" / "Miss" / "Bad" + val handler: Long?, + val lines: List +) + +data class BatchStockOutLineRequest( + val inventoryLotLineId: Long, + val qty: Double +) + +data class BatchStockOutResult( + val stockOutId: Long?, + val processedCount: Int, + val skippedCount: Int = 0 +) \ No newline at end of file From 588eb7f0a894c7effb65992e5980b16ec22864f3 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 27 Apr 2026 17:28:30 +0800 Subject: [PATCH 09/12] fix stock take efficient and jo picking dashboard efficient --- .../jobOrder/service/JoPickOrderService.kt | 102 ++++++---- .../entity/PickExecutionIssueRepository.kt | 8 + .../stock/entity/StockTakeRecordRepository.kt | 21 +++ .../stock/service/StockTakeRecordService.kt | 175 ++++++++++++++---- .../stock/web/StockTakeRecordController.kt | 22 +++ .../stock/web/model/StockTakeRecordReponse.kt | 6 + .../20260427_01_Enson/01_alter_stock_take.sql | 5 + 7 files changed, 259 insertions(+), 80 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260427_01_Enson/01_alter_stock_take.sql diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt index 815dcf4..8a9d264 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt @@ -2906,35 +2906,65 @@ open fun getMaterialPickStatus(date: String?): List { // Get all joPickOrders val joPickOrders = joPickOrderRepository.findAll() - - // Filter by date if provided (filter by jobOrder.planStart date) - val filteredJoPickOrders = if (filterDate != null) { - joPickOrders.filter { joPickOrder -> - val jobOrder = joPickOrder.jobOrderId?.let { - jobOrderRepository.findById(it).orElse(null) - } - jobOrder?.planStart?.toLocalDate() == filterDate - } + if (joPickOrders.isEmpty()) return emptyList() + + // Batch load related Job Orders once to avoid repeated findById calls + val jobOrderIds = joPickOrders.mapNotNull { it.jobOrderId }.distinct() + val jobOrdersById = if (jobOrderIds.isEmpty()) { + emptyMap() } else { - joPickOrders + jobOrderRepository.findAllById(jobOrderIds).associateBy { it.id } } - - // Group by jobOrderId - val groupedByJobOrder = filteredJoPickOrders + + // Filter by date/status in-memory using preloaded Job Orders + val groupedByJobOrder = joPickOrders .filter { joPickOrder -> - val jobOrder = joPickOrder.jobOrderId?.let { - jobOrderRepository.findById(it).orElse(null) - } - jobOrder?.status != JobOrderStatus.COMPLETED + val jobOrder = joPickOrder.jobOrderId?.let { jobOrdersById[it] } ?: return@filter false + val matchesDate = filterDate == null || jobOrder.planStart?.toLocalDate() == filterDate + val notCompleted = jobOrder.status != JobOrderStatus.COMPLETED + matchesDate && notCompleted } .groupBy { it.jobOrderId } + + val allPickOrderIds = groupedByJobOrder.values + .flatten() + .mapNotNull { it.pickOrderId } + .distinct() + + val pickOrdersById = if (allPickOrderIds.isEmpty()) { + emptyMap() + } else { + pickOrderRepository.findAllById(allPickOrderIds).associateBy { it.id } + } + + val allPickOrderLines = if (allPickOrderIds.isEmpty()) { + emptyList() + } else { + pickOrderLineRepository.findAllByPickOrderIdInAndDeletedFalse(allPickOrderIds) + } + val pickOrderLinesByPickOrderId = allPickOrderLines.groupBy { it.pickOrder?.id } + + val pickOrderLineIds = allPickOrderLines.mapNotNull { it.id }.distinct() + val allStockOutLines = if (pickOrderLineIds.isEmpty()) { + emptyList() + } else { + stockOutLineRepository.findAllByPickOrderLineIdInAndDeletedFalse(pickOrderLineIds) + } + val stockOutLinesByPickOrderLineId = allStockOutLines.groupBy { it.pickOrderLine?.id } + + val allIssues = if (allPickOrderIds.isEmpty()) { + emptyList() + } else { + pickExecutionIssueRepository.findAllByPickOrderIdInAndDeletedFalse(allPickOrderIds) + } + val issuesByPickOrderId = allIssues.groupBy { it.pickOrderId } + // Map each job order group to a single MaterialPickStatusItem return groupedByJobOrder.mapNotNull { (jobOrderId, joPickOrdersForJob) -> if (jobOrderId == null) return@mapNotNull null - val jobOrder = jobOrderRepository.findById(jobOrderId).orElse(null) - ?: return@mapNotNull null + val jobOrder = jobOrdersById[jobOrderId] ?: return@mapNotNull null // Get BOM item (finished good/semi-finished product), not BOM Material item val bomItem = jobOrder.bom?.item @@ -2944,31 +2974,29 @@ open fun getMaterialPickStatus(date: String?): List { val pickOrderIds = joPickOrdersForJob.mapNotNull { it.pickOrderId }.distinct() // Aggregate data from all pick orders for this job order - val allPickOrderLines = pickOrderIds.flatMap { poId -> - pickOrderLineRepository.findByPickOrderId(poId) + val allPickOrderLinesForJob = pickOrderIds.flatMap { poId -> + pickOrderLinesByPickOrderId[poId].orEmpty() } // Get all stock out lines for all pick orders of this job order - val allStockOutLines = allPickOrderLines.flatMap { pol -> - stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(pol.id!!) - .mapNotNull { solInfo -> - stockOutLineRepository.findById(solInfo.id).orElse(null) - } + val allStockOutLinesForJob = allPickOrderLinesForJob.flatMap { pol -> + val polId = pol.id ?: return@flatMap emptyList() + stockOutLinesByPickOrderLineId[polId].orEmpty() } // ✅ 修复:startTime = 第一个 item 开始提料的时间 // 只考虑已经开始的 items(status 不是 pending),取最早的 startTime - val pickStartTime = allStockOutLines + val pickStartTime = allStockOutLinesForJob .filter { it.status != null && it.status != "pending" } .mapNotNull { it.startTime } .minOrNull() // Count total items to pick (number of distinct pick order lines) - val numberOfItemsToPick = allPickOrderLines.size + val numberOfItemsToPick = allPickOrderLinesForJob.size // 已结束行数:有至少一条 completed,或整行全是 rejected 都算「已结束」 - val finishedItemsCount = allPickOrderLines.count { pol -> - val stockOutLinesForPol = allStockOutLines.filter { + val finishedItemsCount = allPickOrderLinesForJob.count { pol -> + val stockOutLinesForPol = allStockOutLinesForJob.filter { it.pickOrderLine?.id == pol.id } stockOutLinesForPol.any { it.status == "completed" } || @@ -2977,13 +3005,13 @@ open fun getMaterialPickStatus(date: String?): List { // 只有当所有 items 都已结束(完成或全部拒绝)时,才返回 endTime val pickEndTime = if (finishedItemsCount == numberOfItemsToPick && numberOfItemsToPick > 0) { - val completedEndTime = allStockOutLines + val completedEndTime = allStockOutLinesForJob .filter { it.status == "completed" } .mapNotNull { it.endTime } .maxOrNull() // 若没有任何 completed 的 endTime(例如全部 rejected),用 pick order 的 completeDate 作为结束时间 completedEndTime ?: pickOrderIds.mapNotNull { poId -> - pickOrderRepository.findById(poId).orElse(null)?.completeDate + pickOrdersById[poId]?.completeDate }.maxOrNull() } else { null @@ -2994,7 +3022,7 @@ open fun getMaterialPickStatus(date: String?): List { // Count total items with issues from all pick orders val numberOfItemsWithIssue = pickOrderIds.sumOf { poId -> - pickExecutionIssueRepository.findByPickOrderIdAndDeletedFalse(poId).size + issuesByPickOrderId[poId]?.size ?: 0 } // Get job order quantity and UOM @@ -3002,9 +3030,7 @@ open fun getMaterialPickStatus(date: String?): List { val uom = jobOrder.bom?.uom?.code // Determine pick status - check if all pick orders are completed - val pickOrders = pickOrderIds.mapNotNull { poId -> - pickOrderRepository.findById(poId).orElse(null) - } + val pickOrders = pickOrderIds.mapNotNull { poId -> pickOrdersById[poId] } val pickStatus = when { pickOrders.isEmpty() -> null pickOrders.all { it.status == com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus.COMPLETED } -> "completed" @@ -3013,9 +3039,7 @@ open fun getMaterialPickStatus(date: String?): List { } // Use the first pick order code (or combine if multiple) - val pickOrderCode = pickOrderIds.firstOrNull()?.let { poId -> - pickOrderRepository.findById(poId).orElse(null)?.code - } + val pickOrderCode = pickOrderIds.firstOrNull()?.let { poId -> pickOrdersById[poId]?.code } MaterialPickStatusItem( id = jobOrderId, // Use jobOrderId as id diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickExecutionIssueRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickExecutionIssueRepository.kt index 3422587..5669fec 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickExecutionIssueRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickExecutionIssueRepository.kt @@ -12,6 +12,14 @@ import org.springframework.transaction.annotation.Transactional @Repository interface PickExecutionIssueRepository : JpaRepository { fun findByPickOrderIdAndDeletedFalse(pickOrderId: Long): List + @Query( + """ + SELECT p FROM PickExecutionIssue p + WHERE p.deleted = false + AND p.pickOrderId IN :pickOrderIds + """ + ) + fun findAllByPickOrderIdInAndDeletedFalse(@Param("pickOrderIds") pickOrderIds: List): List fun findByPickOrderLineIdAndDeletedFalse(pickOrderLineId: Long): List fun findByLotIdAndDeletedFalse(lotId: Long): List fun findByPickOrderLineIdAndLotIdAndDeletedFalse( diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt index 4f78083..bd20279 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt @@ -12,7 +12,28 @@ interface StockTakeRecordRepository : AbstractRepository fun findAllByStockTakeIdAndDeletedIsFalse(stockTakeId: Long): List; fun findAllByStockTakeIdInAndDeletedIsFalse(stockTakeIds: Collection): List; fun findAllByStockTakeRoundIdAndDeletedIsFalse(stockTakeRoundId: Long): List; + fun findAllByStockTakeRoundIdInAndDeletedIsFalse(stockTakeRoundIds: Collection): List; fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeRecord?; + fun findAllByStockTakeIdAndWarehouseIdInAndDeletedIsFalse( + stockTakeId: Long, + warehouseIds: Collection + ): List + + @Query( + """ + SELECT r + FROM StockTakeRecord r + WHERE r.deleted = false + AND r.stockTake.id = :stockTakeId + AND r.stockTakeSection = :stockTakeSection + AND r.approverStockTakeQty IS NULL + AND (r.pickerFirstStockTakeQty IS NOT NULL OR r.pickerSecondStockTakeQty IS NOT NULL) + """ + ) + fun findPendingApproverRecordsByStockTakeAndSection( + @Param("stockTakeId") stockTakeId: Long, + @Param("stockTakeSection") stockTakeSection: String + ): List @Query(""" SELECT sl FROM StockLedger sl diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 3a25122..eca5189 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -616,7 +616,7 @@ open class StockTakeRecordService( val warehouse = ill.warehouse val availableQty = (ill.inQty ?: BigDecimal.ZERO) .subtract(ill.outQty ?: BigDecimal.ZERO) - .subtract(ill.holdQty ?: BigDecimal.ZERO) + //.subtract(ill.holdQty ?: BigDecimal.ZERO) val stockTakeRecord = if (stockTakeId != null && inventoryLot?.id != null && warehouse?.id != null) { stockTakeRecordsMap[Pair(inventoryLot.id, warehouse.id)] @@ -770,7 +770,7 @@ open class StockTakeRecordService( val availableQty = (ill.inQty ?: BigDecimal.ZERO) .subtract(ill.outQty ?: BigDecimal.ZERO) - .subtract(ill.holdQty ?: BigDecimal.ZERO) + //.subtract(ill.holdQty ?: BigDecimal.ZERO) availableQty.compareTo(BigDecimal.ZERO) > 0 || recordKeySet.contains(Pair(lotId, whId)) } @@ -873,7 +873,7 @@ open class StockTakeRecordService( val warehouse = ill.warehouse val availableQty = (ill.inQty ?: BigDecimal.ZERO) .subtract(ill.outQty ?: BigDecimal.ZERO) - .subtract(ill.holdQty ?: BigDecimal.ZERO) + //.subtract(ill.holdQty ?: BigDecimal.ZERO) InventoryLotDetailResponse( id = ill.id ?: 0L, @@ -972,7 +972,7 @@ open class StockTakeRecordService( val warehouse = ill.warehouse val availableQty = (ill.inQty ?: BigDecimal.ZERO) .subtract(ill.outQty ?: BigDecimal.ZERO) - .subtract(ill.holdQty ?: BigDecimal.ZERO) + // .subtract(ill.holdQty ?: BigDecimal.ZERO) val inventoryLotLineId = ill.id val stockTakeLine = if (effectiveStockTakeId != null && inventoryLotLineId != null) { @@ -1062,7 +1062,7 @@ open class StockTakeRecordService( val warehouse = ill.warehouse val availableQty = (ill.inQty ?: BigDecimal.ZERO) .subtract(ill.outQty ?: BigDecimal.ZERO) - .subtract(ill.holdQty ?: BigDecimal.ZERO) + // .subtract(ill.holdQty ?: BigDecimal.ZERO) val stockTakeRecord = if (stockTakeId != null && inventoryLot?.id != null && warehouse?.id != null) { stockTakeRecordsMap[Pair(inventoryLot.id, warehouse.id)] @@ -1161,7 +1161,7 @@ open class StockTakeRecordService( // 2. 计算 availableQty val availableQty = (inventoryLotLine.inQty ?: BigDecimal.ZERO) .subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO) - .subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) + //.subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) // 3. 新建、更新預建記錄之第一次盤點、或第二次盤點 val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) { @@ -1303,12 +1303,8 @@ open class StockTakeRecordService( println("Found ${inventoryLotLines.size} inventory lot lines") // 4. 使用 stockTakeId 获取已创建的记录,建立映射以排除它们 - val existingRecordsMap = stockTakeRecordRepository.findAll() - .filter { - !it.deleted && - it.stockTake?.id == request.stockTakeId && - it.warehouse?.id in warehouseIds - } + val existingRecordsMap = stockTakeRecordRepository + .findAllByStockTakeIdAndWarehouseIdInAndDeletedIsFalse(request.stockTakeId, warehouseIds) .associateBy { Pair(it.inventoryLotId ?: 0L, it.warehouse?.id ?: 0L) } @@ -1350,7 +1346,7 @@ open class StockTakeRecordService( // 计算 availableQty val availableQty = (ill.inQty ?: BigDecimal.ZERO) .subtract(ill.outQty ?: BigDecimal.ZERO) - .subtract(ill.holdQty ?: BigDecimal.ZERO) + // .subtract(ill.holdQty ?: BigDecimal.ZERO) // 使用 availableQty 作为 qty,badQty 为 0 val qty = availableQty @@ -1398,12 +1394,6 @@ open class StockTakeRecordService( } } if (successCount > 0) { - val existingRecordsCount = stockTakeRecordRepository.findAll() - .filter { - !it.deleted && - it.stockTake?.id == request.stockTakeId - } - .count() checkAndUpdateStockTakeStatus(request.stockTakeId, request.stockTakeSection) } println("batchSaveStockTakeRecords completed: success=$successCount, errors=$errorCount") @@ -1585,15 +1575,11 @@ open fun batchSaveApproverStockTakeRecords( ?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}") - val stockTakeRecords = stockTakeRecordRepository.findAll() - .filter { - !it.deleted && - it.stockTake?.id == request.stockTakeId && - it.stockTakeSection == request.stockTakeSection && - // 只处理已经有 picker 盘点记录的行(第一或第二次盘点任意一个非 null 即视为已盘点) - (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && - it.approverStockTakeQty == null // 只处理未审批的记录 - } + val stockTakeRecords = stockTakeRecordRepository + .findPendingApproverRecordsByStockTakeAndSection( + stockTakeId = request.stockTakeId, + stockTakeSection = request.stockTakeSection + ) println("Found ${stockTakeRecords.size} records to process") @@ -1711,14 +1697,12 @@ open fun batchSaveApproverStockTakeRecordsAll( val roundStockTakeIds: Set = resolveRoundStockTakeIds(stockTake) - var stockTakeRecords = stockTakeRecordRepository.findAll() + var stockTakeRecords = stockTakeRecordRepository + .findAllByStockTakeIdInAndDeletedIsFalse(roundStockTakeIds) .filter { - !it.deleted && - it.stockTake?.id != null && - it.stockTake!!.id!! in roundStockTakeIds && - // 只处理已经有 picker 盘点记录的行(第一或第二次盘点任意一个非 null 即视为已盘点) - (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && - it.approverStockTakeQty == null + // 只处理已经有 picker 盘点记录的行(第一或第二次盘点任意一个非 null 即视为已盘点) + (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && + it.approverStockTakeQty == null } val sectionParts = request.stockTakeSections ?.split(",") @@ -1855,6 +1839,111 @@ if (itemParts.isNotEmpty()) { ) } +open fun batchSaveApproverStockTakeRecordsByIds( + request: BatchSaveApproverStockTakeByIdsRequest +): BatchSaveApproverStockTakeRecordResponse { + println("batchSaveApproverStockTakeRecordsByIds called for stockTakeId: ${request.stockTakeId}, ids=${request.recordIds.size}") + if (request.recordIds.isEmpty()) { + return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No record IDs provided")) + } + + val user = userRepository.findById(request.approverId).orElse(null) + val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(request.stockTakeId) + ?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}") + + val idSet = request.recordIds.toSet() + val stockTakeRecords = stockTakeRecordRepository.findAllById(request.recordIds) + .filter { + !it.deleted && + (it.id in idSet) && + (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && + it.approverStockTakeQty == null + } + println("Found ${stockTakeRecords.size} records to process by IDs") + if (stockTakeRecords.isEmpty()) { + return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No records found matching criteria")) + } + + var successCount = 0 + var errorCount = 0 + val errors = mutableListOf() + val processedStockTakes = mutableSetOf>() + stockTakeRecords.forEach { record -> + try { + val qty: BigDecimal + val badQty: BigDecimal + + if (record.pickerSecondStockTakeQty != null && record.pickerSecondStockTakeQty!! > BigDecimal.ZERO) { + qty = record.pickerSecondStockTakeQty!! + badQty = record.pickerSecondBadQty ?: BigDecimal.ZERO + } else { + qty = record.pickerFirstStockTakeQty ?: BigDecimal.ZERO + badQty = record.pickerFirstBadQty ?: BigDecimal.ZERO + } + + val bookQty = record.bookQty ?: BigDecimal.ZERO + val varianceQty = qty.subtract(bookQty) + + record.apply { + this.approverId = request.approverId + this.approverName = user?.name + this.approverStockTakeQty = qty + this.approverBadQty = badQty + this.varianceQty = varianceQty + this.status = "completed" + this.approverTime = java.time.LocalDateTime.now() + this.lastSelect = if ( + record.pickerSecondStockTakeQty != null && + record.pickerSecondStockTakeQty!! > BigDecimal.ZERO + ) 2 else 1 + if (this.stockTakeEndTime == null) { + this.stockTakeEndTime = java.time.LocalDateTime.now() + } + } + + stockTakeRecordRepository.save(record) + + if (varianceQty != BigDecimal.ZERO) { + try { + applyVarianceAdjustment(record.stockTake ?: stockTake, record, qty, varianceQty, request.approverId) + } catch (e: Exception) { + logger.error("Failed to apply variance adjustment for record ${record.id}", e) + errorCount++ + errors.add("Record ${record.id}: ${e.message}") + return@forEach + } + } else { + completeStockTakeLineForApproverNoVariance(record.stockTake ?: stockTake, record, qty) + } + + val stId = record.stockTake?.id + val section = record.stockTakeSection + if (stId != null && section != null) { + processedStockTakes.add(Pair(stId, section)) + } + successCount++ + } catch (e: Exception) { + errorCount++ + val errorMsg = "Error processing record ${record.id}: ${e.message}" + errors.add(errorMsg) + logger.error(errorMsg, e) + } + } + + if (successCount > 0) { + processedStockTakes.forEach { (stId, section) -> + checkAndUpdateStockTakeStatus(stId, section) + } + } + + println("batchSaveApproverStockTakeRecordsByIds completed: success=$successCount, errors=$errorCount") + return BatchSaveApproverStockTakeRecordResponse( + successCount = successCount, + errorCount = errorCount, + errors = errors + ) +} + /** * stockTakeRecord 上存的是 inventory_lot.id(批次),不是 inventory_lot_line.id;用倉庫 + 批次找唯一庫存行。 */ @@ -1980,10 +2069,11 @@ private fun applyVarianceAdjustment( // 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。 val itemIdForLedger = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found for stock take ledger") - val latestLedger = stockLedgerRepository.findLatestByItemId(itemIdForLedger).firstOrNull() + val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val newBalance = previousBalance - qtyToRemove.toDouble() + val stockLedger = StockLedger().apply { this.inventory = inventory this.itemId = inventoryLot.item?.id @@ -1996,7 +2086,8 @@ private fun applyVarianceAdjustment( this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id this.date = LocalDate.now() } - stockLedgerRepository.saveAndFlush(stockLedger) + + stockLedgerRepository.save(stockLedger) val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove) val updateRequest = SaveInventoryLotLineRequest( @@ -2058,10 +2149,11 @@ private fun applyVarianceAdjustment( val itemIdForLedger = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") - val latestLedger = stockLedgerRepository.findLatestByItemId(itemIdForLedger).firstOrNull() + val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val newBalance = previousBalance + plusQty.toDouble() + val stockLedger = StockLedger().apply { this.inventory = inventory this.itemId = inventoryLot.item?.id @@ -2074,7 +2166,8 @@ private fun applyVarianceAdjustment( this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id this.date = LocalDate.now() } - stockLedgerRepository.saveAndFlush(stockLedger) + + stockLedgerRepository.save(stockLedger) } } open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTakeRecord { @@ -2146,7 +2239,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( val warehouse = ill.warehouse val availableQty = (ill.inQty ?: BigDecimal.ZERO) .subtract(ill.outQty ?: BigDecimal.ZERO) - .subtract(ill.holdQty ?: BigDecimal.ZERO) + // .subtract(ill.holdQty ?: BigDecimal.ZERO) val inventoryLotLineId = ill.id val stockTakeLine = if (effectiveStockTakeId != null && inventoryLotLineId != null) { @@ -2238,7 +2331,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( val warehouse = ill.warehouse val availableQty = (ill.inQty ?: BigDecimal.ZERO) .subtract(ill.outQty ?: BigDecimal.ZERO) - .subtract(ill.holdQty ?: BigDecimal.ZERO) + //.subtract(ill.holdQty ?: BigDecimal.ZERO) val stockTakeRecord = if (stockTakeId != null && inventoryLot?.id != null && warehouse?.id != null) { stockTakeRecordsMap[Pair(inventoryLot.id, warehouse.id)] diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt index 61cc04d..66cd811 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt @@ -300,6 +300,28 @@ class StockTakeRecordController( )) } } + @PostMapping("/batchSaveApproverStockTakeRecordsByIds") + fun batchSaveApproverStockTakeRecordsByIds( + @RequestBody request: BatchSaveApproverStockTakeByIdsRequest + ): ResponseEntity { + return try { + val result = stockOutRecordService.batchSaveApproverStockTakeRecordsByIds(request) + logger.info("Batch approver save by ids completed: 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 approver stock take records by ids", e) + ResponseEntity.status(500).body(mapOf( + "error" to "INTERNAL_ERROR", + "message" to (e.message ?: "Failed to batch save approver stock take records by ids") + )) + } + } @PostMapping("/updateStockTakeRecordStatusToNotMatch") fun updateStockTakeRecordStatusToNotMatch( @RequestParam stockTakeRecordId: Long diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt index b973363..4c25691 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt @@ -143,6 +143,12 @@ data class BatchSaveApproverStockTakeAllRequest( val stockTakeSections: String? = null, ) +data class BatchSaveApproverStockTakeByIdsRequest( + val stockTakeId: Long, + val approverId: Long, + val recordIds: List, +) + data class BatchSaveApproverStockTakeRecordResponse( val successCount: Int, val errorCount: Int, diff --git a/src/main/resources/db/changelog/changes/20260427_01_Enson/01_alter_stock_take.sql b/src/main/resources/db/changelog/changes/20260427_01_Enson/01_alter_stock_take.sql new file mode 100644 index 0000000..04962e8 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260427_01_Enson/01_alter_stock_take.sql @@ -0,0 +1,5 @@ +--liquibase formatted sql + +--changeset Enson:20260427-01 +CREATE INDEX idx_ledger_item_deleted_date_id +ON stock_ledger (itemId, deleted, date, id); \ No newline at end of file From fcee2542211cd1b59fa9c6d5dfa394cc9a8b1b7c Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 28 Apr 2026 11:19:46 +0800 Subject: [PATCH 10/12] fix do reprint and jo suggestion list --- .../service/DoWorkbenchMainService.kt | 130 ++++++++++++++++-- .../web/DoWorkbenchController.kt | 5 +- .../service/JoWorkbenchMainService.kt | 6 +- 3 files changed, 122 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 0a12eb1..6bba72d 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -70,10 +70,12 @@ import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry import com.ffii.fpsms.modules.master.service.PrinterService import com.ffii.fpsms.modules.master.entity.ItemsRepository import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderPickOrder +import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfo import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDeliveryNoteRequest import com.ffii.fpsms.modules.deliveryOrder.web.models.ExportDeliveryNoteRequest import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsRequest import com.ffii.fpsms.modules.deliveryOrder.web.models.ExportDNLabelsRequest +import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsReprintRequest import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.CountDownLatch @@ -2531,6 +2533,27 @@ return MessageResponse( } } + private fun workbenchCartonLabelParams( + ctx: WorkbenchPrintContext, + cartonLabelInfo: MutableList, + ): MutableMap { + val params = mutableMapOf() + params["shopPurchaseOrderNo"] = if (ctx.deliveryOrderIds.size > 1) { + "請查閲送貨單(採購單共${ctx.deliveryOrderIds.size}張)" + } else { + cartonLabelInfo[0].code + } + params["deliveryNoteCode"] = ctx.header.deliveryNoteCode ?: "" + params["shopAddress"] = cartonLabelInfo[0].shopAddress ?: "" + val rawShopLabel = ctx.header.shopName ?: cartonLabelInfo[0].shopName ?: "" + val parsedShopLabel = deliveryOrderService.parseShopLabelForCartonLabel(rawShopLabel) + params["shopCode"] = parsedShopLabel.shopCode + params["shopCodeAbbr"] = parsedShopLabel.shopCodeAbbr + params["shopName"] = parsedShopLabel.shopNameForLabel + params["truckNo"] = ctx.header.truckLanceCode ?: "" + return params + } + private fun exportDNLabelsWorkbench(request: ExportDNLabelsRequest): Map { val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" val resource = ClassPathResource(DNLABELS_PDF) @@ -2548,23 +2571,9 @@ return MessageResponse( throw NoSuchElementException("Delivery orders not found for IDs: ${ctx.deliveryOrderIds}") } - val params = mutableMapOf() + val params = workbenchCartonLabelParams(ctx, cartonLabelInfo) val fields = mutableListOf>() - params["shopPurchaseOrderNo"] = if (ctx.deliveryOrderIds.size > 1) { - "請查閲送貨單(採購單共${ctx.deliveryOrderIds.size}張)" - } else { - cartonLabelInfo[0].code - } - params["deliveryNoteCode"] = ctx.header.deliveryNoteCode ?: "" - params["shopAddress"] = cartonLabelInfo[0].shopAddress ?: "" - val rawShopLabel = ctx.header.shopName ?: cartonLabelInfo[0].shopName ?: "" - val parsedShopLabel = deliveryOrderService.parseShopLabelForCartonLabel(rawShopLabel) - params["shopCode"] = parsedShopLabel.shopCode - params["shopCodeAbbr"] = parsedShopLabel.shopCodeAbbr - params["shopName"] = parsedShopLabel.shopNameForLabel - params["truckNo"] = ctx.header.truckLanceCode ?: "" - for (cartonNumber in 1..request.numOfCarton) { fields.add( mutableMapOf( @@ -2580,11 +2589,102 @@ return MessageResponse( ) } + private fun validateWorkbenchCartonReprintRange( + fromCarton: Int, + toCarton: Int, + totalCartonsOnShipment: Int, + ) { + require(totalCartonsOnShipment >= 1) { + "totalCartonsOnShipment must be at least 1" + } + require(fromCarton >= 1) { + "fromCarton must be at least 1" + } + require(toCarton >= fromCarton) { + "toCarton must be greater than or equal to fromCarton" + } + require(toCarton <= totalCartonsOnShipment) { + "toCarton must be less than or equal to totalCartonsOnShipment" + } + } + + /** + * Carton label reprint for workbench: [request.doPickOrderId] is [delivery_order_pick_order.id], + * same as [getWorkbenchPrintContext]. Legacy [DeliveryOrderService.printDNLabelsReprint] expects + * [do_pick_order_record.recordId] and must not be used here. + */ + private fun exportDNLabelsReprintWorkbench(request: PrintDNLabelsReprintRequest): Map { + validateWorkbenchCartonReprintRange( + fromCarton = request.fromCarton, + toCarton = request.toCarton, + totalCartonsOnShipment = request.totalCartonsOnShipment, + ) + + val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" + val resource = ClassPathResource(DNLABELS_PDF) + if (!resource.exists()) { + throw FileNotFoundException("Label file not found: $DNLABELS_PDF") + } + + val ctx = getWorkbenchPrintContext(request.doPickOrderId) + val inputStream = resource.inputStream + val cartonLabel = JasperCompileManager.compileReport(inputStream) + val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> + deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) + }.toMutableList() + if (cartonLabelInfo.isEmpty()) { + throw NoSuchElementException("Delivery orders not found for IDs: ${ctx.deliveryOrderIds}") + } + + val params = workbenchCartonLabelParams(ctx, cartonLabelInfo) + val fields = mutableListOf>() + + for (cartonNumber in request.fromCarton..request.toCarton) { + fields.add( + mutableMapOf( + "cartonIndex" to cartonNumber, + "cartonTotal" to request.totalCartonsOnShipment, + ), + ) + } + + return mapOf( + "report" to PdfUtils.fillReport(cartonLabel, fields, params), + "filename" to "${cartonLabelInfo[0].code}_carton_labels_reprint_${request.fromCarton}-${request.toCarton}", + ) + } + private fun updateWorkbenchCartonQty(deliveryOrderPickOrderId: Long, cartonQty: Int) { if (cartonQty <= 0) return val workbenchHeader = deliveryOrderPickOrderRepository.findById(deliveryOrderPickOrderId).orElse(null) ?: return workbenchHeader.cartonQty = cartonQty deliveryOrderPickOrderRepository.save(workbenchHeader) } + @Transactional + open fun printDNLabelsReprint(request: PrintDNLabelsReprintRequest) { + val printer = + printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") + val pdf = exportDNLabelsReprintWorkbench(request) + val jasperPrint = pdf["report"] as JasperPrint + val tempPdfFile = File.createTempFile("print_job_", ".pdf") + try { + JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) + val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty + printer.ip?.let { ip -> + printer.port?.let { port -> + ZebraPrinterUtil.printPdfToZebra( + tempPdfFile, + ip, + port, + printQty, + ZebraPrinterUtil.PrintDirection.ROTATED, + printer.dpi, + ) + } + } + } finally { + // tempPdfFile.delete() + } + } } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt index 4672629..3a2d8a4 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt @@ -207,7 +207,10 @@ class DoWorkbenchController( fun printWorkbenchDNLabels(@ModelAttribute request: PrintDNLabelsRequest) { doWorkbenchMainService.printDNLabelsWorkbench(request) } - + @GetMapping("/print-DNLabels-reprint") + fun printWorkbenchDNLabelsReprint(@ModelAttribute request: PrintDNLabelsReprintRequest) { + doWorkbenchMainService.printDNLabelsReprint(request) + } @GetMapping("/truck-routing-summary/store-options") fun getWorkbenchTruckRoutingStoreOptions(): List> = workbenchTruckRoutingSummaryService.getStoreOptions() diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt index cde8ee4..69eebf7 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt @@ -51,8 +51,8 @@ open class JoWorkbenchMainService( ) { // Keep aligned with SuggestedPickLotWorkbenchService default excludes for diagnosis logs. private val workbenchDefaultExcludeWarehouseCodes: Set = setOf( - "2F-W202-01-00", - "2F-W200-#A-00", + //"2F-W202-01-00", + //"2F-W200-#A-00", ) private fun debugPrintSuggestionNullReasons(pickOrderId: Long) { @@ -522,7 +522,7 @@ open class JoWorkbenchMainService( suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( pickOrderId = pickOrderId, storeId = null, - excludeWarehouseCodes = null, + excludeWarehouseCodes = workbenchDefaultExcludeWarehouseCodes.toList() ) stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId) val splAfter = loadSplDebugRowsByPol(pickOrderId) From 6ed77c2b3eda19c4b519b8a2a8923647488d3d59 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 28 Apr 2026 19:21:34 +0800 Subject: [PATCH 11/12] update jo search --- .../jobOrder/service/JoPickOrderService.kt | 42 +++++++++++++++++-- .../jobOrder/web/JobOrderController.kt | 19 ++++++++- .../web/model/CreateJobOrderRequest.kt | 2 + .../stock/web/InventoryLotLineController.kt | 28 +++++++++++++ 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt index 8a9d264..fa83bb6 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt @@ -2108,7 +2108,15 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List { + open fun getAllJoPickOrders( + bomType: String?, + floor: String?, + jobOrderCode: String? = null, + pickOrderCode: String? = null, + itemName: String? = null, + bomDescription: String? = null, + planStart: LocalDate? = null, + ): List { println("=== getAllJoPickOrders ===") val wallStartNs = System.nanoTime() val timing = linkedMapOf() @@ -2130,7 +2138,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List>> = timed("queryFloorCountsMs") { @@ -2266,7 +2276,10 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List + val jobCodeMatch = normalizedJobOrderCode == null || + (row.jobOrderCode?.lowercase()?.contains(normalizedJobOrderCode) == true) + val pickCodeMatch = normalizedPickOrderCode == null || + (row.pickOrderCode?.lowercase()?.contains(normalizedPickOrderCode) == true) + val itemNameMatch = normalizedItemName == null || + row.itemName.lowercase().contains(normalizedItemName) + val bomDescriptionMatch = normalizedBomDescription == null || + (row.bomDescription?.lowercase()?.contains(normalizedBomDescription) == true) + val planStartMatch = dateFilter == null || row.planStart?.toLocalDate() == dateFilter + jobCodeMatch && pickCodeMatch && itemNameMatch && bomDescriptionMatch && planStartMatch + } + } val totalMs = (System.nanoTime() - wallStartNs) / 1_000_000 println( "JO_ALL_PICK_ORDERS_TIMING totalMs=$totalMs released=${releasedPickOrders.size} " + timing.entries.joinToString(" ") { "${it.key}=${it.value}" }, ) // println("Returning ${jobOrderPickOrders.size} released job order pick orders") - sorted + filtered } catch (e: Exception) { val totalMs = (System.nanoTime() - wallStartNs) / 1_000_000 println( diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt index 462c7b9..0c8fc6e 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt @@ -304,9 +304,24 @@ fun getJobOrderPickOrderLotDetails( @RequestParam(name = "type", required = false) bomType: String?, // Single floor, e.g. "2F"/"3F"/"4F". When provided, backend returns job pick orders // that still have unpicked lines on that floor OR any "no lot" remaining lines. - @RequestParam(required = false) floor: String? + @RequestParam(required = false) floor: String?, + @RequestParam(name = "jobOrderCode", required = false) jobOrderCode: String?, + @RequestParam(name = "pickOrderCode", required = false) pickOrderCode: String?, + @RequestParam(name = "itemName", required = false) itemName: String?, + @RequestParam(name = "bomDescription", required = false) bomDescription: String?, + @RequestParam(name = "planStart", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + planStart: LocalDate?, ): List { - return joPickOrderService.getAllJoPickOrders(bomType, floor) + return joPickOrderService.getAllJoPickOrders( + bomType = bomType, + floor = floor, + jobOrderCode = jobOrderCode, + pickOrderCode = pickOrderCode, + itemName = itemName, + bomDescription = bomDescription, + planStart = planStart, + ) } @GetMapping("/all-lots-hierarchical-by-pick-order/{pickOrderId}") diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt index dbcb012..89a68e4 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt @@ -49,10 +49,12 @@ data class AllJoPickOrderResponse( val jobOrderType: String?, val itemId: Long, val itemName: String, + val bomDescription: String?, val reqQty: BigDecimal, val uomId: Long, val uomName: String, val lotNo: String?, + val planStart: LocalDateTime?, val jobOrderStatus: String, val finishedPickOLineCount: Int, val floorPickCounts: List = emptyList(), diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt index 865d87e..af12378 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt @@ -92,6 +92,34 @@ class InventoryLotLineController ( inventoryLotLineService.printLabelForInventoryLotLine(request) } + /** + * Workbench print endpoint: same printing behavior as `/print-label`, + * but always returns JSON so frontend won't fail on empty-body JSON parsing. + */ + @GetMapping("/workbench/print-label") + fun printLabelWorkbench(@ModelAttribute request: PrintLabelForInventoryLotLineRequest): MessageResponse { + return try { + inventoryLotLineService.printLabelForInventoryLotLine(request) + MessageResponse( + id = request.inventoryLotLineId, + name = "print_label", + code = "SUCCESS", + type = "workbench_print_label", + message = "Print job submitted", + errorPosition = null, + ) + } catch (e: Exception) { + MessageResponse( + id = request.inventoryLotLineId, + name = "print_label", + code = "ERROR", + type = "workbench_print_label", + message = e.message ?: "Print failed", + errorPosition = null, + ) + } + } + @PostMapping("/updateStatus") fun updateInventoryLotLineStatus(@RequestBody request: UpdateInventoryLotLineStatusRequest): MessageResponse { println("=== DEBUG: updateInventoryLotLineStatus Controller ===") From 46874f54e7c6f99cc2b7b7778775267f0582c935 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 28 Apr 2026 23:04:19 +0800 Subject: [PATCH 12/12] update job order type --- .../modules/productProcess/service/ProductProcessService.kt | 2 ++ .../productProcess/web/model/SaveProductProcessRequest.kt | 1 + 2 files changed, 3 insertions(+) diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index deb4a17..c0724f5 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -1459,6 +1459,7 @@ open class ProductProcessService( assignedTo = pickOrder?.assignTo?.id, itemName = productProcesses.item?.name, itemCode = productProcesses.item?.code, + bomDescription = productProcesses.bom?.description, pickOrderId = pickOrder?.id, pickOrderStatus = pickOrder?.status?.value, jobOrderId = productProcesses.jobOrder?.id, @@ -1754,6 +1755,7 @@ open class ProductProcessService( assignedTo = pickOrder?.assignTo?.id, itemName = productProcess.item?.name, itemCode = productProcess.item?.code, + bomDescription = productProcess.bom?.description, pickOrderId = pickOrder?.id, pickOrderStatus = pickOrder?.status?.value, jobOrderId = productProcess.jobOrder?.id, diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt index 7ad8fd8..97f8b55 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt @@ -168,6 +168,7 @@ data class AllJoborderProductProcessInfoResponse( val bomId: Long?, val itemName: String?, val itemCode: String?, + val bomDescription: String?, val matchStatus: String?, val RequiredQty: Int?, val Uom: String?,