| @@ -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 | |||||
| } | |||||
| @@ -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<DeliveryOrderPickOrder, Long> { | |||||
| @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? | |||||
| } | |||||
| @@ -1393,7 +1393,7 @@ open class DeliveryOrderService( | |||||
| ) | ) | ||||
| } | } | ||||
| private fun routeFromStockOutsForItem( | |||||
| fun routeFromStockOutsForItem( | |||||
| itemId: Long, | itemId: Long, | ||||
| pickOrderLineIdsByItemId: Map<Long?, List<Long>>, | pickOrderLineIdsByItemId: Map<Long?, List<Long>>, | ||||
| stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>>, | stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>>, | ||||
| @@ -1480,13 +1480,13 @@ open class DeliveryOrderService( | |||||
| ) | ) | ||||
| } | } | ||||
| private data class ParsedShopLabelForCartonLabel( | |||||
| data class ParsedShopLabelForCartonLabel( | |||||
| val shopCode: String, | val shopCode: String, | ||||
| val shopCodeAbbr: String, | val shopCodeAbbr: String, | ||||
| val shopNameForLabel: String | val shopNameForLabel: String | ||||
| ) | ) | ||||
| private fun parseShopLabelForCartonLabel(rawInput: String): ParsedShopLabelForCartonLabel { | |||||
| fun parseShopLabelForCartonLabel(rawInput: String): ParsedShopLabelForCartonLabel { | |||||
| // Fixed input format: shopCode - shopName1-shopName2 | // Fixed input format: shopCode - shopName1-shopName2 | ||||
| val raw = rawInput.trim() | val raw = rawInput.trim() | ||||
| @@ -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<String, Any>( | |||||
| "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<Map<String, Any?>>): List<Long> { | |||||
| 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<String, Any>( | |||||
| "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 | |||||
| ) | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -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<Pair<Long, String>>, | |||||
| val createdDeliveryOrderPickOrders: Int, | |||||
| ) | |||||
| data class WorkbenchBatchReleaseJobStatus( | |||||
| val jobId: String, | |||||
| val total: Int, | |||||
| val success: AtomicInteger = AtomicInteger(0), | |||||
| val failed: MutableList<Pair<Long, String>> = 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<String, WorkbenchBatchReleaseJobStatus>() | |||||
| 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<Long>, 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<Long>, userId: Long): MessageResponse = | |||||
| startBatchReleaseAsyncInternal(ids, userId, useV2 = true) | |||||
| private fun startBatchReleaseAsyncInternal(ids: List<Long>, 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<ReleaseDoResult>() | |||||
| 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<Long>, 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<Long>, userId: Long): MessageResponse = | |||||
| releaseBatchInternal(ids, userId, useV2 = true) | |||||
| private fun releaseBatchInternal(ids: List<Long>, 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<ReleaseDoResult>() | |||||
| val failed = mutableListOf<Pair<Long, String>>() | |||||
| 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<Long>): List<Long> { | |||||
| 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<ReleaseDoResult>): 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 | |||||
| } | |||||
| } | |||||
| @@ -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<Map<String, String>> { | |||||
| 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<String, Any>()).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<Map<String, String>> { | |||||
| val args = mutableMapOf<String, Any>() | |||||
| 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<Map<String, Any>> { | |||||
| val args = mutableMapOf<String, Any>() | |||||
| 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<String, Any>( | |||||
| "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<String, Any>() | |||||
| 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() | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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<Any>()), | |||||
| ) | |||||
| } | |||||
| 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<ReleasedDoPickOrderListItem> { | |||||
| 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<ReleasedDoPickOrderListItem> { | |||||
| 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<Long>, | |||||
| @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<Long>, | |||||
| @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<Long>, | |||||
| @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<WorkbenchTicketReleaseTableResponse> { | |||||
| 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<String, Any?> { | |||||
| 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<Map<String, String>> = | |||||
| workbenchTruckRoutingSummaryService.getStoreOptions() | |||||
| @GetMapping("/truck-routing-summary/lane-options") | |||||
| fun getWorkbenchTruckRoutingLaneOptions( | |||||
| @RequestParam(required = false) storeId: String?, | |||||
| ): List<Map<String, String>> = | |||||
| 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<String, Any> { | |||||
| 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<ByteArray> { | |||||
| 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<String, Any>( | |||||
| "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) | |||||
| } | |||||
| } | |||||
| @@ -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<WorkbenchScanPickRequest> = emptyList(), | |||||
| ) | |||||
| @@ -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<String>? = 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, | |||||
| ) | |||||
| @@ -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, | |||||
| ) | |||||
| @@ -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<String> = 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<Long, List<SplDebugRow>> { | |||||
| 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<Long, List<SplDebugRow>>, | |||||
| after: Map<Long, List<SplDebugRow>>, | |||||
| ) { | |||||
| 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<Long, Double> = 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<Long, Long> = 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 | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -243,7 +243,7 @@ open class JobOrderService( | |||||
| var sufficientCount = 0 | var sufficientCount = 0 | ||||
| var insufficientCount = 0 | var insufficientCount = 0 | ||||
| println("=== JobOrderService.calculateStockCounts for JobOrder: ${jobOrder.code} ===") | |||||
| //println("=== JobOrderService.calculateStockCounts for JobOrder: ${jobOrder.code} ===") | |||||
| nonConsumablesJobms.forEach { jobm -> | nonConsumablesJobms.forEach { jobm -> | ||||
| val itemId = jobm.item?.id | 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) | return Pair(sufficientCount, insufficientCount) | ||||
| } | } | ||||
| @@ -530,7 +530,19 @@ open class JobOrderService( | |||||
| @Transactional(rollbackFor = [Exception::class]) | @Transactional(rollbackFor = [Exception::class]) | ||||
| open fun releaseJobOrder(request: JobOrderCommonActionRequest): MessageResponse { | 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 { | jo.apply { | ||||
| status = JobOrderStatus.PENDING | status = JobOrderStatus.PENDING | ||||
| } | } | ||||
| @@ -607,6 +619,7 @@ open class JobOrderService( | |||||
| pickOrderEntity.consoCode = consoCode | pickOrderEntity.consoCode = consoCode | ||||
| pickOrderEntity.status = com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus.RELEASED | pickOrderEntity.status = com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus.RELEASED | ||||
| pickOrderRepository.saveAndFlush(pickOrderEntity) | pickOrderRepository.saveAndFlush(pickOrderEntity) | ||||
| if (!deferStockOutUntilAssign) { | |||||
| // Create stock out record and pre-create stock out lines | // Create stock out record and pre-create stock out lines | ||||
| val stockOut = StockOut().apply { | val stockOut = StockOut().apply { | ||||
| this.type = "job" | 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 } | val itemIds = pols.mapNotNull { it.itemId } | ||||
| if (itemIds.isNotEmpty()) { | if (itemIds.isNotEmpty()) { | ||||
| @@ -20,8 +20,8 @@ import org.springframework.web.bind.annotation.RequestBody | |||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||
| import com.ffii.fpsms.modules.jobOrder.service.JoPickOrderService | 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 com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus | ||||
| import org.springframework.format.annotation.DateTimeFormat | import org.springframework.format.annotation.DateTimeFormat | ||||
| @@ -55,8 +55,8 @@ class JobOrderController( | |||||
| private val jobOrderBomMaterialService: JobOrderBomMaterialService, | private val jobOrderBomMaterialService: JobOrderBomMaterialService, | ||||
| private val jobOrderProcessService: JobOrderProcessService, | private val jobOrderProcessService: JobOrderProcessService, | ||||
| private val joPickOrderService: JoPickOrderService, | 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 productProcessService: ProductProcessService, | ||||
| private val jobOrderCreationService: com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService | 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. */ | /** Workbench: release without stock out / SPL / SOL until first pick-order assign. */ | ||||
| /* | |||||
| @PostMapping("/workbench/release") | @PostMapping("/workbench/release") | ||||
| fun releaseJobOrderForWorkbench(@Valid @RequestBody request: JobOrderCommonActionRequest): MessageResponse { | fun releaseJobOrderForWorkbench(@Valid @RequestBody request: JobOrderCommonActionRequest): MessageResponse { | ||||
| return joWorkbenchReleaseService.releaseJobOrderForWorkbench(request) | return joWorkbenchReleaseService.releaseJobOrderForWorkbench(request) | ||||
| } | } | ||||
| */ | |||||
| @PostMapping("/set-hidden") | @PostMapping("/set-hidden") | ||||
| fun setJobOrderHidden(@Valid @RequestBody request: SetJobOrderHiddenRequest): MessageResponse { | fun setJobOrderHidden(@Valid @RequestBody request: SetJobOrderHiddenRequest): MessageResponse { | ||||
| return jobOrderService.setJobOrderHidden(request) | return jobOrderService.setJobOrderHidden(request) | ||||
| @@ -150,7 +150,7 @@ class JobOrderController( | |||||
| } | } | ||||
| /** Workbench: assign + prime SPL/SOL when release was deferred (see [JoWorkbenchMainService]). */ | /** Workbench: assign + prime SPL/SOL when release was deferred (see [JoWorkbenchMainService]). */ | ||||
| /* | |||||
| @PostMapping("/workbench/assign-job-order-pick-order/{pickOrderId}/{userId}") | @PostMapping("/workbench/assign-job-order-pick-order/{pickOrderId}/{userId}") | ||||
| fun assignJobOrderPickOrderToUserForWorkbench( | fun assignJobOrderPickOrderToUserForWorkbench( | ||||
| @PathVariable pickOrderId: Long, | @PathVariable pickOrderId: Long, | ||||
| @@ -158,7 +158,7 @@ class JobOrderController( | |||||
| ): MessageResponse { | ): MessageResponse { | ||||
| return joWorkbenchMainService.assignJobOrderPickOrderToUserForWorkbench(pickOrderId, userId) | return joWorkbenchMainService.assignJobOrderPickOrderToUserForWorkbench(pickOrderId, userId) | ||||
| } | } | ||||
| */ | |||||
| @PostMapping("/unassign-job-order-pick-order/{pickOrderId}") | @PostMapping("/unassign-job-order-pick-order/{pickOrderId}") | ||||
| fun unAssignJobOrderPickOrderToUser( | fun unAssignJobOrderPickOrderToUser( | ||||
| @PathVariable pickOrderId: Long | @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. */ | /** 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}") | @GetMapping("/all-lots-hierarchical-by-pick-order-workbench/{pickOrderId}") | ||||
| fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalResponse { | fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalResponse { | ||||
| return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId) | return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId) | ||||
| } | } | ||||
| */ | |||||
| @PostMapping("/update-jo-pick-order-handled-by") | @PostMapping("/update-jo-pick-order-handled-by") | ||||
| fun updateJoPickOrderHandledBy(@Valid @RequestBody request: UpdateJoPickOrderHandledByRequest): MessageResponse { | fun updateJoPickOrderHandledBy(@Valid @RequestBody request: UpdateJoPickOrderHandledByRequest): MessageResponse { | ||||
| try { | try { | ||||
| @@ -119,7 +119,10 @@ data class StockOutLineDetailResponse( | |||||
| val lotNo: String?, | val lotNo: String?, | ||||
| val location: String?, | val location: String?, | ||||
| val availableQty: Double?, | 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( | data class LotDetailResponse( | ||||
| @@ -2,6 +2,12 @@ package com.ffii.fpsms.modules.jobOrder.web.model | |||||
| data class JobOrderCommonActionRequest( | data class JobOrderCommonActionRequest( | ||||
| val id: Long, | 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( | data class JobOrderUpdateRequest( | ||||
| @@ -33,6 +33,7 @@ interface PickOrderRepository : AbstractRepository<PickOrder, Long> { | |||||
| and (lower(:itemName) = 'all' or lower(pol.item.name) like concat('%',lower(:itemName),'%')) | 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.consoCode = null | ||||
| and po.deleted = false | and po.deleted = false | ||||
| """ | """ | ||||
| @@ -44,6 +45,7 @@ interface PickOrderRepository : AbstractRepository<PickOrder, Long> { | |||||
| type: String, | type: String, | ||||
| status: String, | status: String, | ||||
| itemName: String, | itemName: String, | ||||
| assignTo: Long?, | |||||
| pageable: Pageable, | pageable: Pageable, | ||||
| ): Page<PickOrderInfo> | ): Page<PickOrderInfo> | ||||
| @@ -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<String, Any?>, | |||||
| pickOrdersInfo: List<Map<String, Any?>>, | |||||
| /** 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<String, Any?> { | |||||
| 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<Map<String, Any?>>() | |||||
| val lineCountsPerPickOrder = mutableListOf<Int>() | |||||
| val pickOrderIds = mutableListOf<Long>() | |||||
| val pickOrderCodes = mutableListOf<String>() | |||||
| val doOrderIds = mutableListOf<Long>() | |||||
| val deliveryOrderCodes = mutableListOf<String>() | |||||
| 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<Map<String, Any?>> = 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<Map<String, Any?>> = 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<Map<String, Any?>> | |||||
| val firstLot = lots?.firstOrNull() | |||||
| val router = firstLot?.get("router") as? Map<String, Any?> | |||||
| 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), | |||||
| ) | |||||
| } | |||||
| @@ -167,6 +167,7 @@ open class PickOrderService( | |||||
| type = request.type ?: "all", | type = request.type ?: "all", | ||||
| status = request.status ?: "all", | status = request.status ?: "all", | ||||
| itemName = request.itemName ?: "all", | itemName = request.itemName ?: "all", | ||||
| assignTo = request.assignTo, | |||||
| pageable = pageable | pageable = pageable | ||||
| ) | ) | ||||
| @@ -187,6 +188,7 @@ open class PickOrderService( | |||||
| type = request.type ?: "all", | type = request.type ?: "all", | ||||
| status = request.status ?: "all", | status = request.status ?: "all", | ||||
| itemName = request.itemName ?: "all", | itemName = request.itemName ?: "all", | ||||
| assignTo = request.assignTo, | |||||
| pageable = pageable | pageable = pageable | ||||
| ) | ) | ||||
| @@ -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<Long>, 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<Long>() | |||||
| 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<Long>, 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<String, Any?> { | |||||
| 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<Any>()) | |||||
| } | |||||
| 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<PickOrderLineLotDetailResponse> { | |||||
| 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 | |||||
| } | |||||
| } | |||||
| @@ -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.PickOrderRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.projection.PickOrderInfo | import com.ffii.fpsms.modules.pickOrder.entity.projection.PickOrderInfo | ||||
| import com.ffii.fpsms.modules.pickOrder.service.PickOrderService | 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.* | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.ConsoPickOrderRequest | import com.ffii.fpsms.modules.pickOrder.web.models.ConsoPickOrderRequest | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.ConsoPickOrderResponse | 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.entity.projection.PickOrderGroupInfo | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.GetPickOrderInfoResponse | import com.ffii.fpsms.modules.pickOrder.web.models.GetPickOrderInfoResponse | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.LotSubstitutionConfirmRequest | import com.ffii.fpsms.modules.pickOrder.web.models.LotSubstitutionConfirmRequest | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/pickOrder") | @RequestMapping("/pickOrder") | ||||
| class PickOrderController( | class PickOrderController( | ||||
| private val pickOrderService: PickOrderService, | private val pickOrderService: PickOrderService, | ||||
| private val pickOrderWorkbenchService: PickOrderWorkbenchService, | |||||
| private val pickOrderRepository: PickOrderRepository, | private val pickOrderRepository: PickOrderRepository, | ||||
| private val doWorkbenchMainService: DoWorkbenchMainService, | |||||
| ) { | ) { | ||||
| @GetMapping("/list") | @GetMapping("/list") | ||||
| fun allPickOrders(): List<PickOrderInfo> { | fun allPickOrders(): List<PickOrderInfo> { | ||||
| @@ -77,6 +81,30 @@ class PickOrderController( | |||||
| return pickOrderService.assignPickOrders(pickOrderIds, assignTo) | return pickOrderService.assignPickOrders(pickOrderIds, assignTo) | ||||
| } | } | ||||
| @PostMapping("/workbench/assign-v2") | |||||
| fun assignPickOrdersWorkbenchV2(@RequestBody request: Map<String, Any>): 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<String, Any>): 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<String, Any?> { | |||||
| return pickOrderWorkbenchService.getAllPickOrderLotsWithDetailsHierarchicalWorkbench(userId) | |||||
| } | |||||
| @GetMapping("/workbench/line-detail-v2/{pickOrderLineId}") | |||||
| fun getWorkbenchPickOrderLineDetailV2(@PathVariable pickOrderLineId: Long): List<PickOrderLineLotDetailResponse> { | |||||
| return pickOrderWorkbenchService.getWorkbenchPickOrderLineLotDetailsV2(pickOrderLineId) | |||||
| } | |||||
| // Release Pick Orders (without consoCode) | // Release Pick Orders (without consoCode) | ||||
| @PostMapping("/release") | @PostMapping("/release") | ||||
| fun releasePickOrders(@RequestBody request: Map<String, Any>): MessageResponse { | fun releasePickOrders(@RequestBody request: Map<String, Any>): MessageResponse { | ||||
| @@ -273,9 +301,19 @@ class PickOrderController( | |||||
| return pickOrderService.getAllPickOrderLotsWithDetailsWithoutAutoAssign(userId) | return pickOrderService.getAllPickOrderLotsWithDetailsWithoutAutoAssign(userId) | ||||
| } | } | ||||
| @GetMapping("/all-lots-hierarchical/{userId}") | @GetMapping("/all-lots-hierarchical/{userId}") | ||||
| fun getAllPickOrderLotsHierarchical(@PathVariable userId: Long): Map<String, Any?> { | |||||
| return pickOrderService.getAllPickOrderLotsWithDetailsHierarchical(userId) | |||||
| } | |||||
| fun getAllPickOrderLotsHierarchical(@PathVariable userId: Long): Map<String, Any?> { | |||||
| return pickOrderService.getAllPickOrderLotsWithDetailsHierarchical(userId) | |||||
| } | |||||
| @GetMapping("/fg-pick-orders-workbench/{userId}") | |||||
| fun getFgPickOrdersByUserIdWorkbench(@PathVariable userId: Long): List<Map<String, Any?>> { | |||||
| return doWorkbenchMainService.getFgPickOrdersByUserIdWorkbench(userId) | |||||
| } | |||||
| @GetMapping("/all-lots-hierarchical-workbench/{userId}") | |||||
| fun getAllPickOrderLotsHierarchicalWorkbench(@PathVariable userId: Long): Map<String, Any?> { | |||||
| return doWorkbenchMainService.getAllPickOrderLotsWithDetailsHierarchicalWorkbench(userId) | |||||
| } | |||||
| /* @PostMapping("/auto-assign-release-by-ticket") | /* @PostMapping("/auto-assign-release-by-ticket") | ||||
| fun autoAssignAndReleasePickOrderByTicket( | fun autoAssignAndReleasePickOrderByTicket( | ||||
| @RequestParam storeId: String, | @RequestParam storeId: String, | ||||
| @@ -311,6 +349,39 @@ fun getCompletedDoPickOrders( | |||||
| return pickOrderService.getCompletedDoPickOrders(userId, request) | 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<CompletedDoPickOrderResponse> { | |||||
| 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<CompletedDoPickOrderResponse> { | |||||
| val request = GetCompletedDoPickOrdersRequest( | |||||
| targetDate = targetDate, | |||||
| shopName = shopName, | |||||
| deliveryNoteCode = deliveryNoteCode, | |||||
| truckLanceCode = truckLanceCode, | |||||
| ) | |||||
| return doWorkbenchMainService.getCompletedDoPickOrdersWorkbenchAll(request) | |||||
| } | |||||
| @GetMapping("/completed-do-pick-orders-all") | @GetMapping("/completed-do-pick-orders-all") | ||||
| fun getCompletedDoPickOrdersAll( | fun getCompletedDoPickOrdersAll( | ||||
| @RequestParam(required = false) shopName: String?, | @RequestParam(required = false) shopName: String?, | ||||
| @@ -14,6 +14,8 @@ data class SearchPickOrderRequest ( | |||||
| val itemName: String?, | val itemName: String?, | ||||
| val pageSize: Int?, | val pageSize: Int?, | ||||
| val pageNum: Int?, | val pageNum: Int?, | ||||
| /** When set, restrict to pick orders assigned to this user id. */ | |||||
| val assignTo: Long? = null, | |||||
| ) | ) | ||||
| data class GetCompletedDoPickOrdersRequest( | data class GetCompletedDoPickOrdersRequest( | ||||
| val targetDate: String? = null, | val targetDate: String? = null, | ||||
| @@ -30,20 +30,19 @@ interface InventoryLotLineRepository : AbstractRepository<InventoryLotLine, Long | |||||
| @Transactional | @Transactional | ||||
| @Query( | @Query( | ||||
| value = """ | value = """ | ||||
| UPDATE inventory_lot_line ill | |||||
| SET ill.outQty = COALESCE(ill.outQty, 0) + :delta, | |||||
| ill.status = CASE | |||||
| WHEN LOWER(COALESCE(ill.status, '')) = 'available' | |||||
| AND (COALESCE(ill.inQty, 0) - (COALESCE(ill.outQty, 0) + :delta)) > 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 | nativeQuery = true | ||||
| ) | ) | ||||
| @@ -58,6 +58,18 @@ fun findAllByPickOrderLineIdInAndDeletedFalse( | |||||
| @Param("pickOrderLineIds") pickOrderLineIds: List<Long> | @Param("pickOrderLineIds") pickOrderLineIds: List<Long> | ||||
| ): List<StockOutLine> | ): List<StockOutLine> | ||||
| /** 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<Long> | |||||
| ): List<StockOutLineInfo> | |||||
| // 添加批量查询方法:按 (pickOrderLineId, inventoryLotLineId) 组合查询 | // 添加批量查询方法:按 (pickOrderLineId, inventoryLotLineId) 组合查询 | ||||
| @Query(""" | @Query(""" | ||||
| SELECT sol FROM StockOutLine sol | SELECT sol FROM StockOutLine sol | ||||
| @@ -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.QrCodeAnalysisResponse | ||||
| import com.ffii.fpsms.modules.stock.web.model.ScannedLotInfo | 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.SameItemLotInfo | ||||
| import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderService | import com.ffii.fpsms.modules.jobOrder.service.JobOrderService | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | ||||
| import com.ffii.fpsms.modules.master.service.PrinterService | import com.ffii.fpsms.modules.master.service.PrinterService | ||||
| @@ -106,7 +107,17 @@ open class InventoryLotLineService( | |||||
| else -> InventoryLotLineStatus.UNAVAILABLE | 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 { | open fun saveInventoryLotLine(request: SaveInventoryLotLineRequest): InventoryLotLine { | ||||
| val inventoryLotLine = | val inventoryLotLine = | ||||
| request.id?.let { inventoryLotLineRepository.findById(it).getOrNull() } ?: 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( | SameItemLotInfo( | ||||
| lotNo = lotNo, | lotNo = lotNo, | ||||
| inventoryLotLineId = lotLine.id!!, | |||||
| inventoryLotLineId = lotLine.id ?: return@mapNotNull null, | |||||
| availableQty = remainingQty, | availableQty = remainingQty, | ||||
| uom = uomDesc, | 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, | |||||
| ) | |||||
| } | |||||
| } | } | ||||
| @@ -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<String> = 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") | |||||
| } | |||||
| } | |||||
| @@ -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<String> = 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<String>): Boolean { | |||||
| if (excludeWarehouseCodes.isEmpty()) return false | |||||
| val code = lot.warehouse?.code?.trim()?.uppercase() | |||||
| return !code.isNullOrEmpty() && code in excludeWarehouseCodes | |||||
| } | |||||
| private fun effectiveExcludeWarehouseCodes(raw: List<String>?): Set<String> { | |||||
| 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<String>? = 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<Long, BigDecimal> = 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<String>? = 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<Long, BigDecimal> = 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<String>? = 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<String>? = 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<String>? = 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<InventoryLotLine>, | |||||
| remainingInit: BigDecimal, | |||||
| pickOrderLineIdForLog: Long, | |||||
| ): List<Pair<InventoryLotLine?, BigDecimal>> { | |||||
| val out = mutableListOf<Pair<InventoryLotLine?, BigDecimal>>() | |||||
| 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<SuggestedPickLot>): List<SuggestedPickLot> { | |||||
| val byKey = existing.groupBy { it.suggestedLotLine?.id } | |||||
| val keepers = mutableListOf<SuggestedPickLot>() | |||||
| val dupSoftDelete = mutableListOf<SuggestedPickLot>() | |||||
| 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<Pair<InventoryLotLine?, BigDecimal>>, | |||||
| ): 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<SuggestedPickLot>() | |||||
| 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<Long>() | |||||
| val toPersist = mutableListOf<SuggestedPickLot>() | |||||
| 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 | |||||
| } | |||||
| } | |||||
| @@ -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<StockOutLineInfo>): BigDecimal = | |||||
| infos.filter { isCountedAsPicked(it) }.fold(BigDecimal.ZERO) { acc, i -> acc.add(i.qty) } | |||||
| } | |||||
| @@ -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.UpdateInventoryLotLineStatusRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest | 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.QrCodeAnalysisResponse | ||||
| import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse | |||||
| import org.springframework.http.HttpStatus | import org.springframework.http.HttpStatus | ||||
| import org.springframework.web.server.ResponseStatusException | import org.springframework.web.server.ResponseStatusException | ||||
| @@ -117,4 +118,16 @@ class InventoryLotLineController ( | |||||
| fun analyzeQrCode(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { | fun analyzeQrCode(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { | ||||
| return inventoryLotLineService.analyzeQrCode(request) | 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) | |||||
| } | |||||
| } | } | ||||
| @@ -17,7 +17,7 @@ data class UpdateInventoryLotLineStatusRequest( | |||||
| ) | ) | ||||
| data class QrCodeAnalysisRequest( | data class QrCodeAnalysisRequest( | ||||
| val itemId: Long, | val itemId: Long, | ||||
| val stockInLineId: Long | |||||
| val stockInLineId: Long, | |||||
| ) | ) | ||||
| data class ScannedLotInfo( | data class ScannedLotInfo( | ||||
| @@ -43,4 +43,11 @@ data class QrCodeAnalysisResponse( | |||||
| val itemName: String, | val itemName: String, | ||||
| val scanned: ScannedLotInfo, | val scanned: ScannedLotInfo, | ||||
| val sameItemLots: List<SameItemLotInfo> | val sameItemLots: List<SameItemLotInfo> | ||||
| ) | |||||
| data class WorkbenchItemLotsResponse( | |||||
| val itemId: Long, | |||||
| val itemCode: String, | |||||
| val itemName: String, | |||||
| val sameItemLots: List<SameItemLotInfo> | |||||
| ) | ) | ||||
| @@ -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`) | |||||
| ); | |||||
| @@ -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; | |||||
| @@ -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); | |||||