| @@ -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? | |||
| } | |||
| @@ -1402,7 +1402,7 @@ open class DeliveryOrderService( | |||
| ) | |||
| } | |||
| private fun routeFromStockOutsForItem( | |||
| fun routeFromStockOutsForItem( | |||
| itemId: Long, | |||
| pickOrderLineIdsByItemId: Map<Long?, List<Long>>, | |||
| stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>>, | |||
| @@ -1505,13 +1505,13 @@ open class DeliveryOrderService( | |||
| ) | |||
| } | |||
| private data class ParsedShopLabelForCartonLabel( | |||
| data class ParsedShopLabelForCartonLabel( | |||
| val shopCode: String, | |||
| val shopCodeAbbr: String, | |||
| val shopNameForLabel: String | |||
| ) | |||
| private fun parseShopLabelForCartonLabel(rawInput: String): ParsedShopLabelForCartonLabel { | |||
| fun parseShopLabelForCartonLabel(rawInput: String): ParsedShopLabelForCartonLabel { | |||
| // Fixed input format: shopCode - shopName1-shopName2 | |||
| val raw = rawInput.trim() | |||
| @@ -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,275 @@ | |||
| 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("/print-DNLabels-reprint") | |||
| fun printWorkbenchDNLabelsReprint(@ModelAttribute request: PrintDNLabelsReprintRequest) { | |||
| doWorkbenchMainService.printDNLabelsReprint(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, | |||
| ) | |||
| @@ -2108,7 +2108,15 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| return if (num.isNotEmpty()) "${num}F" else cleaned | |||
| } | |||
| open fun getAllJoPickOrders(bomType: String?, floor: String?): List<AllJoPickOrderResponse> { | |||
| open fun getAllJoPickOrders( | |||
| bomType: String?, | |||
| floor: String?, | |||
| jobOrderCode: String? = null, | |||
| pickOrderCode: String? = null, | |||
| itemName: String? = null, | |||
| bomDescription: String? = null, | |||
| planStart: LocalDate? = null, | |||
| ): List<AllJoPickOrderResponse> { | |||
| println("=== getAllJoPickOrders ===") | |||
| val wallStartNs = System.nanoTime() | |||
| val timing = linkedMapOf<String, Long>() | |||
| @@ -2130,7 +2138,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| // println("Found ${releasedPickOrders.size} released job order pick orders") | |||
| val pickOrderIds = releasedPickOrders.mapNotNull { it.id } | |||
| val normalizedFloorFilter = floor?.let { normalizeFloor(it) }?.takeIf { it.isNotBlank() } | |||
| val normalizedFloorFilter = floor | |||
| ?.let { normalizeFloor(it) } | |||
| ?.takeIf { it.isNotBlank() && it != "ALL" } | |||
| // 2. 批量查询每个 pick order 的按楼层统计(若没有则跳过) | |||
| val floorCountsByPickOrderId: Map<Long, List<Map<String, Any?>>> = timed("queryFloorCountsMs") { | |||
| @@ -2266,7 +2276,10 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| val bom = jobOrder.bom | |||
| // 按 bom.type 过滤:null / blank 表示不过滤(全部) | |||
| val normalizedType = bomType?.trim()?.lowercase()?.takeIf { it.isNotBlank() } | |||
| val normalizedType = bomType | |||
| ?.trim() | |||
| ?.lowercase() | |||
| ?.takeIf { it.isNotBlank() && it != "all" } | |||
| if (normalizedType != null) { | |||
| val currentType = bom?.type?.trim()?.lowercase() | |||
| if (currentType != normalizedType) return@mapNotNull null | |||
| @@ -2339,11 +2352,13 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| jobOrderType = jobOrderType?.name, | |||
| itemId = item.id ?: 0L, | |||
| itemName = item.name ?: "", | |||
| bomDescription = bom?.description, | |||
| reqQty = jobOrder.reqQty ?: BigDecimal.ZERO, | |||
| //uomId = bom.outputQtyUom?.id : 0L, | |||
| uomId = 0, | |||
| uomName = bom?.outputQtyUom ?: "", | |||
| lotNo = lotNo, | |||
| planStart = jobOrder.planStart, | |||
| jobOrderStatus = jobOrder.status?.value ?: "", | |||
| finishedPickOLineCount = finishedLines, | |||
| floorPickCounts = floorPickCounts, | |||
| @@ -2355,13 +2370,32 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| val sorted = timed("sortResponseMs") { | |||
| jobOrderPickOrders.sortedByDescending { it.id } | |||
| } | |||
| val normalizedJobOrderCode = jobOrderCode?.trim()?.lowercase()?.takeIf { it.isNotBlank() } | |||
| val normalizedPickOrderCode = pickOrderCode?.trim()?.lowercase()?.takeIf { it.isNotBlank() } | |||
| val normalizedItemName = itemName?.trim()?.lowercase()?.takeIf { it.isNotBlank() } | |||
| val normalizedBomDescription = bomDescription?.trim()?.lowercase()?.takeIf { it.isNotBlank() } | |||
| val dateFilter = planStart | |||
| val filtered = timed("applySearchFilterMs") { | |||
| sorted.filter { row -> | |||
| val jobCodeMatch = normalizedJobOrderCode == null || | |||
| (row.jobOrderCode?.lowercase()?.contains(normalizedJobOrderCode) == true) | |||
| val pickCodeMatch = normalizedPickOrderCode == null || | |||
| (row.pickOrderCode?.lowercase()?.contains(normalizedPickOrderCode) == true) | |||
| val itemNameMatch = normalizedItemName == null || | |||
| row.itemName.lowercase().contains(normalizedItemName) | |||
| val bomDescriptionMatch = normalizedBomDescription == null || | |||
| (row.bomDescription?.lowercase()?.contains(normalizedBomDescription) == true) | |||
| val planStartMatch = dateFilter == null || row.planStart?.toLocalDate() == dateFilter | |||
| jobCodeMatch && pickCodeMatch && itemNameMatch && bomDescriptionMatch && planStartMatch | |||
| } | |||
| } | |||
| val totalMs = (System.nanoTime() - wallStartNs) / 1_000_000 | |||
| println( | |||
| "JO_ALL_PICK_ORDERS_TIMING totalMs=$totalMs released=${releasedPickOrders.size} " + | |||
| timing.entries.joinToString(" ") { "${it.key}=${it.value}" }, | |||
| ) | |||
| // println("Returning ${jobOrderPickOrders.size} released job order pick orders") | |||
| sorted | |||
| filtered | |||
| } catch (e: Exception) { | |||
| val totalMs = (System.nanoTime() - wallStartNs) / 1_000_000 | |||
| println( | |||
| @@ -2906,35 +2940,65 @@ open fun getMaterialPickStatus(date: String?): List<MaterialPickStatusItem> { | |||
| // Get all joPickOrders | |||
| val joPickOrders = joPickOrderRepository.findAll() | |||
| // Filter by date if provided (filter by jobOrder.planStart date) | |||
| val filteredJoPickOrders = if (filterDate != null) { | |||
| joPickOrders.filter { joPickOrder -> | |||
| val jobOrder = joPickOrder.jobOrderId?.let { | |||
| jobOrderRepository.findById(it).orElse(null) | |||
| } | |||
| jobOrder?.planStart?.toLocalDate() == filterDate | |||
| } | |||
| if (joPickOrders.isEmpty()) return emptyList() | |||
| // Batch load related Job Orders once to avoid repeated findById calls | |||
| val jobOrderIds = joPickOrders.mapNotNull { it.jobOrderId }.distinct() | |||
| val jobOrdersById = if (jobOrderIds.isEmpty()) { | |||
| emptyMap() | |||
| } else { | |||
| joPickOrders | |||
| jobOrderRepository.findAllById(jobOrderIds).associateBy { it.id } | |||
| } | |||
| // Group by jobOrderId | |||
| val groupedByJobOrder = filteredJoPickOrders | |||
| // Filter by date/status in-memory using preloaded Job Orders | |||
| val groupedByJobOrder = joPickOrders | |||
| .filter { joPickOrder -> | |||
| val jobOrder = joPickOrder.jobOrderId?.let { | |||
| jobOrderRepository.findById(it).orElse(null) | |||
| } | |||
| jobOrder?.status != JobOrderStatus.COMPLETED | |||
| val jobOrder = joPickOrder.jobOrderId?.let { jobOrdersById[it] } ?: return@filter false | |||
| val matchesDate = filterDate == null || jobOrder.planStart?.toLocalDate() == filterDate | |||
| val notCompleted = jobOrder.status != JobOrderStatus.COMPLETED | |||
| matchesDate && notCompleted | |||
| } | |||
| .groupBy { it.jobOrderId } | |||
| val allPickOrderIds = groupedByJobOrder.values | |||
| .flatten() | |||
| .mapNotNull { it.pickOrderId } | |||
| .distinct() | |||
| val pickOrdersById = if (allPickOrderIds.isEmpty()) { | |||
| emptyMap() | |||
| } else { | |||
| pickOrderRepository.findAllById(allPickOrderIds).associateBy { it.id } | |||
| } | |||
| val allPickOrderLines = if (allPickOrderIds.isEmpty()) { | |||
| emptyList() | |||
| } else { | |||
| pickOrderLineRepository.findAllByPickOrderIdInAndDeletedFalse(allPickOrderIds) | |||
| } | |||
| val pickOrderLinesByPickOrderId = allPickOrderLines.groupBy { it.pickOrder?.id } | |||
| val pickOrderLineIds = allPickOrderLines.mapNotNull { it.id }.distinct() | |||
| val allStockOutLines = if (pickOrderLineIds.isEmpty()) { | |||
| emptyList() | |||
| } else { | |||
| stockOutLineRepository.findAllByPickOrderLineIdInAndDeletedFalse(pickOrderLineIds) | |||
| } | |||
| val stockOutLinesByPickOrderLineId = allStockOutLines.groupBy { it.pickOrderLine?.id } | |||
| val allIssues = if (allPickOrderIds.isEmpty()) { | |||
| emptyList() | |||
| } else { | |||
| pickExecutionIssueRepository.findAllByPickOrderIdInAndDeletedFalse(allPickOrderIds) | |||
| } | |||
| val issuesByPickOrderId = allIssues.groupBy { it.pickOrderId } | |||
| // Map each job order group to a single MaterialPickStatusItem | |||
| return groupedByJobOrder.mapNotNull { (jobOrderId, joPickOrdersForJob) -> | |||
| if (jobOrderId == null) return@mapNotNull null | |||
| val jobOrder = jobOrderRepository.findById(jobOrderId).orElse(null) | |||
| ?: return@mapNotNull null | |||
| val jobOrder = jobOrdersById[jobOrderId] ?: return@mapNotNull null | |||
| // Get BOM item (finished good/semi-finished product), not BOM Material item | |||
| val bomItem = jobOrder.bom?.item | |||
| @@ -2944,31 +3008,29 @@ open fun getMaterialPickStatus(date: String?): List<MaterialPickStatusItem> { | |||
| val pickOrderIds = joPickOrdersForJob.mapNotNull { it.pickOrderId }.distinct() | |||
| // Aggregate data from all pick orders for this job order | |||
| val allPickOrderLines = pickOrderIds.flatMap { poId -> | |||
| pickOrderLineRepository.findByPickOrderId(poId) | |||
| val allPickOrderLinesForJob = pickOrderIds.flatMap { poId -> | |||
| pickOrderLinesByPickOrderId[poId].orEmpty() | |||
| } | |||
| // Get all stock out lines for all pick orders of this job order | |||
| val allStockOutLines = allPickOrderLines.flatMap { pol -> | |||
| stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(pol.id!!) | |||
| .mapNotNull { solInfo -> | |||
| stockOutLineRepository.findById(solInfo.id).orElse(null) | |||
| } | |||
| val allStockOutLinesForJob = allPickOrderLinesForJob.flatMap { pol -> | |||
| val polId = pol.id ?: return@flatMap emptyList() | |||
| stockOutLinesByPickOrderLineId[polId].orEmpty() | |||
| } | |||
| // ✅ 修复:startTime = 第一个 item 开始提料的时间 | |||
| // 只考虑已经开始的 items(status 不是 pending),取最早的 startTime | |||
| val pickStartTime = allStockOutLines | |||
| val pickStartTime = allStockOutLinesForJob | |||
| .filter { it.status != null && it.status != "pending" } | |||
| .mapNotNull { it.startTime } | |||
| .minOrNull() | |||
| // Count total items to pick (number of distinct pick order lines) | |||
| val numberOfItemsToPick = allPickOrderLines.size | |||
| val numberOfItemsToPick = allPickOrderLinesForJob.size | |||
| // 已结束行数:有至少一条 completed,或整行全是 rejected 都算「已结束」 | |||
| val finishedItemsCount = allPickOrderLines.count { pol -> | |||
| val stockOutLinesForPol = allStockOutLines.filter { | |||
| val finishedItemsCount = allPickOrderLinesForJob.count { pol -> | |||
| val stockOutLinesForPol = allStockOutLinesForJob.filter { | |||
| it.pickOrderLine?.id == pol.id | |||
| } | |||
| stockOutLinesForPol.any { it.status == "completed" } || | |||
| @@ -2977,13 +3039,13 @@ open fun getMaterialPickStatus(date: String?): List<MaterialPickStatusItem> { | |||
| // 只有当所有 items 都已结束(完成或全部拒绝)时,才返回 endTime | |||
| val pickEndTime = if (finishedItemsCount == numberOfItemsToPick && numberOfItemsToPick > 0) { | |||
| val completedEndTime = allStockOutLines | |||
| val completedEndTime = allStockOutLinesForJob | |||
| .filter { it.status == "completed" } | |||
| .mapNotNull { it.endTime } | |||
| .maxOrNull() | |||
| // 若没有任何 completed 的 endTime(例如全部 rejected),用 pick order 的 completeDate 作为结束时间 | |||
| completedEndTime ?: pickOrderIds.mapNotNull { poId -> | |||
| pickOrderRepository.findById(poId).orElse(null)?.completeDate | |||
| pickOrdersById[poId]?.completeDate | |||
| }.maxOrNull() | |||
| } else { | |||
| null | |||
| @@ -2994,7 +3056,7 @@ open fun getMaterialPickStatus(date: String?): List<MaterialPickStatusItem> { | |||
| // Count total items with issues from all pick orders | |||
| val numberOfItemsWithIssue = pickOrderIds.sumOf { poId -> | |||
| pickExecutionIssueRepository.findByPickOrderIdAndDeletedFalse(poId).size | |||
| issuesByPickOrderId[poId]?.size ?: 0 | |||
| } | |||
| // Get job order quantity and UOM | |||
| @@ -3002,9 +3064,7 @@ open fun getMaterialPickStatus(date: String?): List<MaterialPickStatusItem> { | |||
| val uom = jobOrder.bom?.uom?.code | |||
| // Determine pick status - check if all pick orders are completed | |||
| val pickOrders = pickOrderIds.mapNotNull { poId -> | |||
| pickOrderRepository.findById(poId).orElse(null) | |||
| } | |||
| val pickOrders = pickOrderIds.mapNotNull { poId -> pickOrdersById[poId] } | |||
| val pickStatus = when { | |||
| pickOrders.isEmpty() -> null | |||
| pickOrders.all { it.status == com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus.COMPLETED } -> "completed" | |||
| @@ -3013,9 +3073,7 @@ open fun getMaterialPickStatus(date: String?): List<MaterialPickStatusItem> { | |||
| } | |||
| // Use the first pick order code (or combine if multiple) | |||
| val pickOrderCode = pickOrderIds.firstOrNull()?.let { poId -> | |||
| pickOrderRepository.findById(poId).orElse(null)?.code | |||
| } | |||
| val pickOrderCode = pickOrderIds.firstOrNull()?.let { poId -> pickOrdersById[poId]?.code } | |||
| MaterialPickStatusItem( | |||
| id = jobOrderId, // Use jobOrderId as id | |||
| @@ -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 = workbenchDefaultExcludeWarehouseCodes.toList() | |||
| ) | |||
| 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 insufficientCount = 0 | |||
| println("=== JobOrderService.calculateStockCounts for JobOrder: ${jobOrder.code} ===") | |||
| //println("=== JobOrderService.calculateStockCounts for JobOrder: ${jobOrder.code} ===") | |||
| nonConsumablesJobms.forEach { jobm -> | |||
| val itemId = jobm.item?.id | |||
| @@ -328,7 +328,7 @@ open class JobOrderService( | |||
| } | |||
| } | |||
| println("=== Result: sufficient=$sufficientCount, insufficient=$insufficientCount ===") | |||
| //println("=== Result: sufficient=$sufficientCount, insufficient=$insufficientCount ===") | |||
| return Pair(sufficientCount, insufficientCount) | |||
| } | |||
| @@ -530,7 +530,19 @@ open class JobOrderService( | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| open fun releaseJobOrder(request: JobOrderCommonActionRequest): MessageResponse { | |||
| val jo = request.id.let { jobOrderRepository.findById(it).getOrNull() } ?: throw NoSuchElementException() | |||
| return releaseJobOrderInternal(request.id, deferStockOutUntilAssign = false) | |||
| } | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| open fun releaseJobOrderForWorkbench(request: JobOrderCommonActionRequest): MessageResponse { | |||
| return releaseJobOrderInternal(request.id, deferStockOutUntilAssign = true) | |||
| } | |||
| private fun releaseJobOrderInternal( | |||
| jobOrderId: Long, | |||
| deferStockOutUntilAssign: Boolean, | |||
| ): MessageResponse { | |||
| val jo = jobOrderId.let { jobOrderRepository.findById(it).getOrNull() } ?: throw NoSuchElementException() | |||
| jo.apply { | |||
| status = JobOrderStatus.PENDING | |||
| } | |||
| @@ -607,6 +619,7 @@ open class JobOrderService( | |||
| pickOrderEntity.consoCode = consoCode | |||
| pickOrderEntity.status = com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus.RELEASED | |||
| pickOrderRepository.saveAndFlush(pickOrderEntity) | |||
| if (!deferStockOutUntilAssign) { | |||
| // Create stock out record and pre-create stock out lines | |||
| val stockOut = StockOut().apply { | |||
| this.type = "job" | |||
| @@ -681,6 +694,9 @@ open class JobOrderService( | |||
| } | |||
| } | |||
| } | |||
| } else { | |||
| println("JO workbench release defer enabled: skip legacy suggestion/hold/stockout prebuild. pickOrderId=${pickOrderEntity.id}") | |||
| } | |||
| } | |||
| val itemIds = pols.mapNotNull { it.itemId } | |||
| if (itemIds.isNotEmpty()) { | |||
| @@ -20,8 +20,8 @@ import org.springframework.web.bind.annotation.RequestBody | |||
| import org.springframework.web.bind.annotation.RequestMapping | |||
| import org.springframework.web.bind.annotation.RestController | |||
| import com.ffii.fpsms.modules.jobOrder.service.JoPickOrderService | |||
| //import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchMainService | |||
| //import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchReleaseService | |||
| import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchMainService | |||
| import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchReleaseService | |||
| import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus | |||
| import org.springframework.format.annotation.DateTimeFormat | |||
| @@ -55,8 +55,8 @@ class JobOrderController( | |||
| private val jobOrderBomMaterialService: JobOrderBomMaterialService, | |||
| private val jobOrderProcessService: JobOrderProcessService, | |||
| private val joPickOrderService: JoPickOrderService, | |||
| //private val joWorkbenchMainService: JoWorkbenchMainService, | |||
| //private val joWorkbenchReleaseService: JoWorkbenchReleaseService, | |||
| private val joWorkbenchMainService: JoWorkbenchMainService, | |||
| private val joWorkbenchReleaseService: JoWorkbenchReleaseService, | |||
| private val productProcessService: ProductProcessService, | |||
| private val jobOrderCreationService: com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService | |||
| ) { | |||
| @@ -96,12 +96,12 @@ class JobOrderController( | |||
| } | |||
| /** Workbench: release without stock out / SPL / SOL until first pick-order assign. */ | |||
| /* | |||
| @PostMapping("/workbench/release") | |||
| fun releaseJobOrderForWorkbench(@Valid @RequestBody request: JobOrderCommonActionRequest): MessageResponse { | |||
| return joWorkbenchReleaseService.releaseJobOrderForWorkbench(request) | |||
| } | |||
| */ | |||
| @PostMapping("/set-hidden") | |||
| fun setJobOrderHidden(@Valid @RequestBody request: SetJobOrderHiddenRequest): MessageResponse { | |||
| return jobOrderService.setJobOrderHidden(request) | |||
| @@ -150,7 +150,7 @@ class JobOrderController( | |||
| } | |||
| /** Workbench: assign + prime SPL/SOL when release was deferred (see [JoWorkbenchMainService]). */ | |||
| /* | |||
| @PostMapping("/workbench/assign-job-order-pick-order/{pickOrderId}/{userId}") | |||
| fun assignJobOrderPickOrderToUserForWorkbench( | |||
| @PathVariable pickOrderId: Long, | |||
| @@ -158,7 +158,7 @@ class JobOrderController( | |||
| ): MessageResponse { | |||
| return joWorkbenchMainService.assignJobOrderPickOrderToUserForWorkbench(pickOrderId, userId) | |||
| } | |||
| */ | |||
| @PostMapping("/unassign-job-order-pick-order/{pickOrderId}") | |||
| fun unAssignJobOrderPickOrderToUser( | |||
| @PathVariable pickOrderId: Long | |||
| @@ -304,9 +304,24 @@ fun getJobOrderPickOrderLotDetails( | |||
| @RequestParam(name = "type", required = false) bomType: String?, | |||
| // Single floor, e.g. "2F"/"3F"/"4F". When provided, backend returns job pick orders | |||
| // that still have unpicked lines on that floor OR any "no lot" remaining lines. | |||
| @RequestParam(required = false) floor: String? | |||
| @RequestParam(required = false) floor: String?, | |||
| @RequestParam(name = "jobOrderCode", required = false) jobOrderCode: String?, | |||
| @RequestParam(name = "pickOrderCode", required = false) pickOrderCode: String?, | |||
| @RequestParam(name = "itemName", required = false) itemName: String?, | |||
| @RequestParam(name = "bomDescription", required = false) bomDescription: String?, | |||
| @RequestParam(name = "planStart", required = false) | |||
| @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) | |||
| planStart: LocalDate?, | |||
| ): List<AllJoPickOrderResponse> { | |||
| return joPickOrderService.getAllJoPickOrders(bomType, floor) | |||
| return joPickOrderService.getAllJoPickOrders( | |||
| bomType = bomType, | |||
| floor = floor, | |||
| jobOrderCode = jobOrderCode, | |||
| pickOrderCode = pickOrderCode, | |||
| itemName = itemName, | |||
| bomDescription = bomDescription, | |||
| planStart = planStart, | |||
| ) | |||
| } | |||
| @GetMapping("/all-lots-hierarchical-by-pick-order/{pickOrderId}") | |||
| @@ -315,12 +330,12 @@ fun getJobOrderPickOrderLotDetails( | |||
| } | |||
| /** Workbench: available qty uses in−out (matches scan-pick); stockouts include suggested SPL qty when matched. */ | |||
| /* | |||
| @GetMapping("/all-lots-hierarchical-by-pick-order-workbench/{pickOrderId}") | |||
| fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalResponse { | |||
| return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId) | |||
| } | |||
| */ | |||
| @PostMapping("/update-jo-pick-order-handled-by") | |||
| fun updateJoPickOrderHandledBy(@Valid @RequestBody request: UpdateJoPickOrderHandledByRequest): MessageResponse { | |||
| try { | |||
| @@ -49,10 +49,12 @@ data class AllJoPickOrderResponse( | |||
| val jobOrderType: String?, | |||
| val itemId: Long, | |||
| val itemName: String, | |||
| val bomDescription: String?, | |||
| val reqQty: BigDecimal, | |||
| val uomId: Long, | |||
| val uomName: String, | |||
| val lotNo: String?, | |||
| val planStart: LocalDateTime?, | |||
| val jobOrderStatus: String, | |||
| val finishedPickOLineCount: Int, | |||
| val floorPickCounts: List<FloorPickCountDto> = emptyList(), | |||
| @@ -119,7 +121,10 @@ data class StockOutLineDetailResponse( | |||
| val lotNo: String?, | |||
| val location: String?, | |||
| val availableQty: Double?, | |||
| val noLot: Boolean | |||
| val noLot: Boolean, | |||
| /** Matched `suggest_pick_lot.qty` for this SOL’s `inventory_lot_line` (null if no SPL row). */ | |||
| val suggestedPickQty: Double? = null, | |||
| val suggestedPickLotId: Long? = null, | |||
| ) | |||
| data class LotDetailResponse( | |||
| @@ -2,6 +2,12 @@ package com.ffii.fpsms.modules.jobOrder.web.model | |||
| data class JobOrderCommonActionRequest( | |||
| val id: Long, | |||
| /** | |||
| * When true (Jo Workbench release): create pick order + Jo tickets only — no [StockOut], | |||
| * suggested pick lots, hold qty, or stock out lines. Those are created on first | |||
| * workbench assign, aligned with DO workbench. | |||
| */ | |||
| val deferStockOutUntilAssign: Boolean? = null, | |||
| ) | |||
| data class JobOrderUpdateRequest( | |||
| @@ -269,7 +269,7 @@ open class ItemsService( | |||
| open fun getPickOrderItemsByPage(args: Map<String, Any>): List<Map<String, Any>> { | |||
| try { | |||
| println("=== Debug: getPickOrderItemsByPage in ItemsService ===") | |||
| println("Args: $args") | |||
| //println("Args: $args") | |||
| val sql = StringBuilder( | |||
| """ | |||
| @@ -333,12 +333,12 @@ open class ItemsService( | |||
| sql.append(" ORDER BY po.targetDate DESC, i.name ASC ") | |||
| val finalSql = sql.toString() | |||
| println("Final SQL: $finalSql") | |||
| println("SQL args: $args") | |||
| // println("Final SQL: $finalSql") | |||
| //println("SQL args: $args") | |||
| val result = jdbcDao.queryForList(finalSql, args) | |||
| println("Query result size: ${result.size}") | |||
| result.forEach { row -> println("Result row: $row") } | |||
| //println("Query result size: ${result.size}") | |||
| // result.forEach { row -> println("Result row: $row") } | |||
| return result | |||
| } catch (e: Exception) { | |||
| println("Error in getPickOrderItemsByPage: ${e.message}") | |||
| @@ -12,6 +12,14 @@ import org.springframework.transaction.annotation.Transactional | |||
| @Repository | |||
| interface PickExecutionIssueRepository : JpaRepository<PickExecutionIssue, Long> { | |||
| fun findByPickOrderIdAndDeletedFalse(pickOrderId: Long): List<PickExecutionIssue> | |||
| @Query( | |||
| """ | |||
| SELECT p FROM PickExecutionIssue p | |||
| WHERE p.deleted = false | |||
| AND p.pickOrderId IN :pickOrderIds | |||
| """ | |||
| ) | |||
| fun findAllByPickOrderIdInAndDeletedFalse(@Param("pickOrderIds") pickOrderIds: List<Long>): List<PickExecutionIssue> | |||
| fun findByPickOrderLineIdAndDeletedFalse(pickOrderLineId: Long): List<PickExecutionIssue> | |||
| fun findByLotIdAndDeletedFalse(lotId: Long): List<PickExecutionIssue> | |||
| fun findByPickOrderLineIdAndLotIdAndDeletedFalse( | |||
| @@ -33,6 +33,7 @@ interface PickOrderRepository : AbstractRepository<PickOrder, Long> { | |||
| and (lower(:itemName) = 'all' or lower(pol.item.name) like concat('%',lower(:itemName),'%')) | |||
| ) | |||
| ) | |||
| and (:assignTo is null or (po.assignTo is not null and po.assignTo.id = :assignTo)) | |||
| and po.consoCode = null | |||
| and po.deleted = false | |||
| """ | |||
| @@ -44,6 +45,7 @@ interface PickOrderRepository : AbstractRepository<PickOrder, Long> { | |||
| type: String, | |||
| status: String, | |||
| itemName: String, | |||
| assignTo: Long?, | |||
| pageable: Pageable, | |||
| ): 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), | |||
| ) | |||
| } | |||
| @@ -42,13 +42,15 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | |||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | |||
| import com.ffii.fpsms.modules.master.entity.ItemUomRespository | |||
| import com.ffii.fpsms.modules.common.CodeGenerator | |||
| import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult | |||
| import com.ffii.fpsms.modules.stock.entity.StockLedger | |||
| import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | |||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | |||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | |||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | |||
| import org.springframework.beans.factory.annotation.Value | |||
| import com.ffii.fpsms.modules.stock.web.model.BatchStockOutRequest | |||
| import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest | |||
| @Service | |||
| open class PickExecutionIssueService( | |||
| private val pickExecutionIssueRepository: PickExecutionIssueRepository, | |||
| @@ -2202,54 +2204,48 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse { | |||
| open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse { | |||
| try { | |||
| val lotLines = inventoryLotLineRepository.findAllById(request.lotLineIds) | |||
| .filter { | |||
| .filter { | |||
| val lot = it.inventoryLot | |||
| val today = LocalDate.now() | |||
| lot?.expiryDate != null && lot.expiryDate!!.isBefore(today) && | |||
| lot?.expiryDate != null && | |||
| lot.expiryDate!!.isBefore(today) && | |||
| (it.inQty ?: BigDecimal.ZERO) != (it.outQty ?: BigDecimal.ZERO) | |||
| } | |||
| if (lotLines.isEmpty()) { | |||
| return MessageResponse( | |||
| id = null, | |||
| name = "Error", | |||
| code = "EMPTY", | |||
| type = "stock_issue", | |||
| message = "No valid expiry items to submit", | |||
| errorPosition = null | |||
| id = null, name = "Error", code = "EMPTY", type = "stock_issue", | |||
| message = "No valid expiry items to submit", errorPosition = null | |||
| ) | |||
| } | |||
| val handler = request.handler ?: SecurityUtils.getUser().orElse(null)?.id ?: 0L | |||
| lotLines.forEach { lotLine -> | |||
| val remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO) | |||
| stockOutLineService.createStockOut( | |||
| StockOutRequest( | |||
| inventoryLotLineId = lotLine.id!!, | |||
| qty = remainingQty.toDouble(), | |||
| type = "Expiry" | |||
| val batchReq = BatchStockOutRequest( | |||
| type = "Expiry", | |||
| handler = handler, | |||
| lines = lotLines.map { ll -> | |||
| val remaining = (ll.inQty ?: BigDecimal.ZERO).subtract(ll.outQty ?: BigDecimal.ZERO) | |||
| BatchStockOutLineRequest( | |||
| inventoryLotLineId = ll.id!!, | |||
| qty = remaining.toDouble() | |||
| ) | |||
| ) | |||
| } | |||
| } | |||
| ) | |||
| val result = stockOutLineService.createStockOutBatch(batchReq) | |||
| return MessageResponse( | |||
| id = null, | |||
| id = result.stockOutId, | |||
| name = "Success", | |||
| code = "SUCCESS", | |||
| type = "stock_issue", | |||
| message = "Successfully submitted ${lotLines.size} expiry item(s)", | |||
| message = "Successfully submitted ${result.processedCount} expiry item(s), skipped ${result.skippedCount}", | |||
| errorPosition = null | |||
| ) | |||
| } catch (e: Exception) { | |||
| return MessageResponse( | |||
| id = null, | |||
| name = "Error", | |||
| code = "ERROR", | |||
| type = "stock_issue", | |||
| message = "Failed to submit expiry items: ${e.message}", | |||
| errorPosition = null | |||
| id = null, name = "Error", code = "ERROR", type = "stock_issue", | |||
| message = "Failed to submit expiry items: ${e.message}", errorPosition = null | |||
| ) | |||
| } | |||
| } | |||
| @@ -167,6 +167,7 @@ open class PickOrderService( | |||
| type = request.type ?: "all", | |||
| status = request.status ?: "all", | |||
| itemName = request.itemName ?: "all", | |||
| assignTo = request.assignTo, | |||
| pageable = pageable | |||
| ) | |||
| @@ -187,6 +188,7 @@ open class PickOrderService( | |||
| type = request.type ?: "all", | |||
| status = request.status ?: "all", | |||
| itemName = request.itemName ?: "all", | |||
| assignTo = request.assignTo, | |||
| pageable = pageable | |||
| ) | |||
| @@ -941,6 +943,7 @@ open class PickOrderService( | |||
| lotNo = il?.lotNo, | |||
| expiryDate = il?.expiryDate, | |||
| location = w?.code, | |||
| stockInLineId = il?.stockInLine?.id, | |||
| stockUnit = ill.stockUom?.uom?.udfudesc ?: uomDesc, | |||
| availableQty = availableQty, | |||
| requiredQty = spl?.qty ?: zero, | |||
| @@ -972,6 +975,7 @@ open class PickOrderService( | |||
| lotNo = null, | |||
| expiryDate = null, | |||
| location = null, | |||
| stockInLineId = null, | |||
| stockUnit = uomDesc, | |||
| availableQty = null, | |||
| requiredQty = zero, | |||
| @@ -0,0 +1,447 @@ | |||
| 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. | |||
| * - Do NOT create suggestion/stock_out_line here (created on Tab3 line select). | |||
| */ | |||
| @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 = pickOrders.mapNotNull { it.id } | |||
| return MessageResponse( | |||
| id = null, | |||
| name = "Workbench assign v2 success", | |||
| code = "SUCCESS", | |||
| type = "pickorder_workbench", | |||
| message = "Assigned pick orders for workbench (no suggestion/stock-out at assign stage)", | |||
| errorPosition = null, | |||
| entity = mapOf( | |||
| "pickOrderIds" to assignedIds | |||
| ) | |||
| ) | |||
| } 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.stockInLineId AS stockInLineId, | |||
| 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(), | |||
| itemId = pol.item?.id, | |||
| stockInLineId = toLong(r["stockInLineId"]), | |||
| 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, | |||
| itemId = pol.item?.id, | |||
| stockInLineId = 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 | |||
| } | |||
| /** | |||
| * Workbench suggest V2 for consumable pick orders (first-time suggestion path): | |||
| * - Create/rebuild no-hold suggestions for this pick order. | |||
| * - Ensure stock_out_line rows exist for created/reused suggestions. | |||
| */ | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| open fun suggestPickOrderV2(pickOrderId: Long, userId: Long): MessageResponse { | |||
| return try { | |||
| val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) | |||
| ?: return MessageResponse( | |||
| id = pickOrderId, | |||
| name = "Workbench suggest v2 failed", | |||
| code = "ERROR", | |||
| type = "pickorder_workbench", | |||
| message = "Pick order not found", | |||
| errorPosition = null | |||
| ) | |||
| val suggestSummary = suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( | |||
| pickOrderId = pickOrderId, | |||
| storeId = null, | |||
| excludeWarehouseCodes = null, | |||
| ) | |||
| val stockOutSummary = stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold( | |||
| pickOrderId = pickOrderId, | |||
| userId = userId, | |||
| ) | |||
| MessageResponse( | |||
| id = pickOrder.id, | |||
| name = "Workbench suggest v2 success", | |||
| code = "SUCCESS", | |||
| type = "pickorder_workbench", | |||
| message = "Suggestion success", | |||
| errorPosition = null, | |||
| entity = mapOf( | |||
| "pickOrderId" to pickOrderId, | |||
| "suggestionRowsCreated" to suggestSummary.created, | |||
| "suggestionRowsSkippedExisting" to suggestSummary.skippedExisting, | |||
| "stockOutLinesCreated" to stockOutSummary.created, | |||
| "stockOutLinesReused" to stockOutSummary.reused, | |||
| ) | |||
| ) | |||
| } catch (e: Exception) { | |||
| MessageResponse( | |||
| id = pickOrderId, | |||
| name = "Workbench suggest v2 failed", | |||
| code = "ERROR", | |||
| type = "pickorder_workbench", | |||
| message = "Failed to prepare workbench suggestion: ${e.message}", | |||
| errorPosition = null | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -7,6 +7,7 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository | |||
| import com.ffii.fpsms.modules.pickOrder.entity.projection.PickOrderInfo | |||
| import com.ffii.fpsms.modules.pickOrder.service.PickOrderService | |||
| import com.ffii.fpsms.modules.pickOrder.service.PickOrderWorkbenchService | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.* | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.ConsoPickOrderRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.ConsoPickOrderResponse | |||
| @@ -35,11 +36,14 @@ import java.time.LocalDate | |||
| import com.ffii.fpsms.modules.pickOrder.entity.projection.PickOrderGroupInfo | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.GetPickOrderInfoResponse | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.LotSubstitutionConfirmRequest | |||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService | |||
| @RestController | |||
| @RequestMapping("/pickOrder") | |||
| class PickOrderController( | |||
| private val pickOrderService: PickOrderService, | |||
| private val pickOrderWorkbenchService: PickOrderWorkbenchService, | |||
| private val pickOrderRepository: PickOrderRepository, | |||
| private val doWorkbenchMainService: DoWorkbenchMainService, | |||
| ) { | |||
| @GetMapping("/list") | |||
| fun allPickOrders(): List<PickOrderInfo> { | |||
| @@ -77,6 +81,48 @@ class PickOrderController( | |||
| 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) | |||
| } | |||
| @PostMapping("/workbench/suggest-v2/{pickOrderId}") | |||
| fun suggestPickOrderWorkbenchV2( | |||
| @PathVariable pickOrderId: Long, | |||
| @RequestBody request: Map<String, Any>, | |||
| ): MessageResponse { | |||
| val userId = request["userId"]?.toString()?.toLongOrNull() | |||
| ?: request["assignTo"]?.toString()?.toLongOrNull() | |||
| ?: return MessageResponse( | |||
| id = pickOrderId, | |||
| name = "Workbench suggest v2 failed", | |||
| code = "ERROR", | |||
| type = "pickorder_workbench", | |||
| message = "userId is required", | |||
| errorPosition = null | |||
| ) | |||
| return pickOrderWorkbenchService.suggestPickOrderV2(pickOrderId, userId) | |||
| } | |||
| // Release Pick Orders (without consoCode) | |||
| @PostMapping("/release") | |||
| fun releasePickOrders(@RequestBody request: Map<String, Any>): MessageResponse { | |||
| @@ -273,9 +319,19 @@ class PickOrderController( | |||
| return pickOrderService.getAllPickOrderLotsWithDetailsWithoutAutoAssign(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") | |||
| fun autoAssignAndReleasePickOrderByTicket( | |||
| @RequestParam storeId: String, | |||
| @@ -311,6 +367,39 @@ fun getCompletedDoPickOrders( | |||
| return pickOrderService.getCompletedDoPickOrders(userId, request) | |||
| } | |||
| @GetMapping("/completed-do-pick-orders-workbench/{userId}") | |||
| fun getCompletedDoPickOrdersWorkbench( | |||
| @PathVariable userId: Long, | |||
| @RequestParam(required = false) shopName: String?, | |||
| @RequestParam(required = false) targetDate: String?, | |||
| @RequestParam(required = false) deliveryNoteCode: String?, | |||
| @RequestParam(required = false) truckLanceCode: String?, | |||
| ): List<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") | |||
| fun getCompletedDoPickOrdersAll( | |||
| @RequestParam(required = false) shopName: String?, | |||
| @@ -162,6 +162,8 @@ data class PickOrderLineLotDetailResponse( | |||
| val lotNo: String?, | |||
| val expiryDate: LocalDate?, | |||
| val location: String?, | |||
| val itemId: Long? = null, | |||
| val stockInLineId: Long? = null, | |||
| val stockUnit: String?, | |||
| val availableQty: BigDecimal?, | |||
| val requiredQty: BigDecimal?, | |||
| @@ -14,6 +14,8 @@ data class SearchPickOrderRequest ( | |||
| val itemName: String?, | |||
| val pageSize: Int?, | |||
| val pageNum: Int?, | |||
| /** When set, restrict to pick orders assigned to this user id. */ | |||
| val assignTo: Long? = null, | |||
| ) | |||
| data class GetCompletedDoPickOrdersRequest( | |||
| val targetDate: String? = null, | |||
| @@ -1459,6 +1459,7 @@ open class ProductProcessService( | |||
| assignedTo = pickOrder?.assignTo?.id, | |||
| itemName = productProcesses.item?.name, | |||
| itemCode = productProcesses.item?.code, | |||
| bomDescription = productProcesses.bom?.description, | |||
| pickOrderId = pickOrder?.id, | |||
| pickOrderStatus = pickOrder?.status?.value, | |||
| jobOrderId = productProcesses.jobOrder?.id, | |||
| @@ -1754,6 +1755,7 @@ open class ProductProcessService( | |||
| assignedTo = pickOrder?.assignTo?.id, | |||
| itemName = productProcess.item?.name, | |||
| itemCode = productProcess.item?.code, | |||
| bomDescription = productProcess.bom?.description, | |||
| pickOrderId = pickOrder?.id, | |||
| pickOrderStatus = pickOrder?.status?.value, | |||
| jobOrderId = productProcess.jobOrder?.id, | |||
| @@ -168,6 +168,7 @@ data class AllJoborderProductProcessInfoResponse( | |||
| val bomId: Long?, | |||
| val itemName: String?, | |||
| val itemCode: String?, | |||
| val bomDescription: String?, | |||
| val matchStatus: String?, | |||
| val RequiredQty: Int?, | |||
| val Uom: String?, | |||
| @@ -30,20 +30,19 @@ interface InventoryLotLineRepository : AbstractRepository<InventoryLotLine, Long | |||
| @Transactional | |||
| @Query( | |||
| 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 | |||
| ) | |||
| @@ -263,4 +262,18 @@ interface InventoryLotLineRepository : AbstractRepository<InventoryLotLine, Long | |||
| """ | |||
| ) | |||
| fun countByStatusAndDeletedIsFalse(@Param("status") status: InventoryLotLineStatus): Long | |||
| @EntityGraph( | |||
| type = EntityGraph.EntityGraphType.FETCH, | |||
| attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"] | |||
| ) | |||
| @Query(""" | |||
| SELECT ill | |||
| FROM InventoryLotLine ill | |||
| WHERE ill.id IN :ids | |||
| AND ill.deleted = false | |||
| """) | |||
| fun findAllByIdInAndDeletedFalseWithRefs( | |||
| @Param("ids") ids: Collection<Long> | |||
| ): List<InventoryLotLine> | |||
| } | |||
| @@ -58,6 +58,18 @@ fun findAllByPickOrderLineIdInAndDeletedFalse( | |||
| @Param("pickOrderLineIds") pickOrderLineIds: List<Long> | |||
| ): 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) 组合查询 | |||
| @Query(""" | |||
| SELECT sol FROM StockOutLine sol | |||
| @@ -12,7 +12,28 @@ interface StockTakeRecordRepository : AbstractRepository<StockTakeRecord, Long> | |||
| fun findAllByStockTakeIdAndDeletedIsFalse(stockTakeId: Long): List<StockTakeRecord>; | |||
| fun findAllByStockTakeIdInAndDeletedIsFalse(stockTakeIds: Collection<Long>): List<StockTakeRecord>; | |||
| fun findAllByStockTakeRoundIdAndDeletedIsFalse(stockTakeRoundId: Long): List<StockTakeRecord>; | |||
| fun findAllByStockTakeRoundIdInAndDeletedIsFalse(stockTakeRoundIds: Collection<Long>): List<StockTakeRecord>; | |||
| fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeRecord?; | |||
| fun findAllByStockTakeIdAndWarehouseIdInAndDeletedIsFalse( | |||
| stockTakeId: Long, | |||
| warehouseIds: Collection<Long> | |||
| ): List<StockTakeRecord> | |||
| @Query( | |||
| """ | |||
| SELECT r | |||
| FROM StockTakeRecord r | |||
| WHERE r.deleted = false | |||
| AND r.stockTake.id = :stockTakeId | |||
| AND r.stockTakeSection = :stockTakeSection | |||
| AND r.approverStockTakeQty IS NULL | |||
| AND (r.pickerFirstStockTakeQty IS NOT NULL OR r.pickerSecondStockTakeQty IS NOT NULL) | |||
| """ | |||
| ) | |||
| fun findPendingApproverRecordsByStockTakeAndSection( | |||
| @Param("stockTakeId") stockTakeId: Long, | |||
| @Param("stockTakeSection") stockTakeSection: String | |||
| ): List<StockTakeRecord> | |||
| @Query(""" | |||
| SELECT sl FROM StockLedger sl | |||
| @@ -38,6 +38,7 @@ import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest | |||
| import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse | |||
| import com.ffii.fpsms.modules.stock.web.model.ScannedLotInfo | |||
| import com.ffii.fpsms.modules.stock.web.model.SameItemLotInfo | |||
| import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse | |||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderService | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | |||
| import com.ffii.fpsms.modules.master.service.PrinterService | |||
| @@ -106,7 +107,17 @@ open class InventoryLotLineService( | |||
| else -> InventoryLotLineStatus.UNAVAILABLE | |||
| } | |||
| } | |||
| fun deriveInventoryLotLineStatusForWorkbench( | |||
| inQty: BigDecimal?, | |||
| outQty: BigDecimal? | |||
| ): InventoryLotLineStatus { | |||
| val remaining = (inQty ?: BigDecimal.ZERO) - (outQty ?: BigDecimal.ZERO) | |||
| return if (remaining > BigDecimal.ZERO) { | |||
| InventoryLotLineStatus.AVAILABLE | |||
| } else { | |||
| InventoryLotLineStatus.UNAVAILABLE | |||
| } | |||
| } | |||
| open fun saveInventoryLotLine(request: SaveInventoryLotLineRequest): InventoryLotLine { | |||
| val inventoryLotLine = | |||
| request.id?.let { inventoryLotLineRepository.findById(it).getOrNull() } ?: InventoryLotLine() | |||
| @@ -404,65 +415,125 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit | |||
| } | |||
| } | |||
| open fun analyzeQrCode(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { | |||
| val stockInLine = stockInLineRepository.findById(request.stockInLineId).orElseThrow() | |||
| // Try direct link first; fall back to first lot line in the linked inventoryLot | |||
| val scannedInventoryLotLine = | |||
| stockInLine.inventoryLotLine | |||
| ?: stockInLine.inventoryLot?.inventoryLotLines?.firstOrNull() | |||
| ?: throw IllegalStateException("No inventory lot line found for stockInLineId=${request.stockInLineId}") | |||
| val item = scannedInventoryLotLine.inventoryLot?.item | |||
| ?: throw IllegalStateException("Item not found for lot line id=${scannedInventoryLotLine.id}") | |||
| // Collect same-item available lots; skip the scanned one; only remainingQty > 0 | |||
| val sameItemLots = inventoryLotLineRepository | |||
| .findAllByInventoryLotItemIdAndStatus(request.itemId, InventoryLotLineStatus.AVAILABLE) | |||
| .asSequence() | |||
| .filter { it.id != scannedInventoryLotLine.id } | |||
| .mapNotNull { lotLine -> | |||
| val lot = lotLine.inventoryLot ?: return@mapNotNull null | |||
| val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null | |||
| val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: return@mapNotNull null | |||
| val whCode = lotLine.warehouse?.code | |||
| val whName = lotLine.warehouse?.name | |||
| val inQty = lotLine.inQty ?: BigDecimal.ZERO | |||
| val outQty = lotLine.outQty ?: BigDecimal.ZERO | |||
| val holdQty = lotLine.holdQty ?: BigDecimal.ZERO | |||
| val remainingQty = inQty.minus(outQty).minus(holdQty) | |||
| if (remainingQty > BigDecimal.ZERO) | |||
| /** | |||
| * Legacy / FG label modal: remaining qty = in - out - hold. | |||
| */ | |||
| open fun analyzeQrCode(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { | |||
| return analyzeQrCodeInternal(request, availableInOutOnly = false) | |||
| } | |||
| /** | |||
| * DO workbench label modal: pickable qty = in - out (aligned with workbench scan-pick). | |||
| */ | |||
| open fun analyzeQrCodeWorkbench(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { | |||
| return analyzeQrCodeInternal(request, availableInOutOnly = true) | |||
| } | |||
| /** | |||
| * DO workbench label modal fallback (no stockInLineId/no-lot row): | |||
| * list available lots by item only, using pickable qty = in - out. | |||
| */ | |||
| open fun listWorkbenchAvailableLotsByItem(itemId: Long): WorkbenchItemLotsResponse { | |||
| val source = inventoryLotLineRepository | |||
| .findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) | |||
| .asSequence() | |||
| .filter { !it.deleted && it.inventoryLot?.item != null } | |||
| .toList() | |||
| val item = source.firstOrNull()?.inventoryLot?.item | |||
| ?: throw IllegalStateException("Item not found for itemId=$itemId") | |||
| val sameItemLots = source | |||
| .mapNotNull { lotLine -> | |||
| val lot = lotLine.inventoryLot ?: return@mapNotNull null | |||
| val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null | |||
| val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: return@mapNotNull null | |||
| val inQty = lotLine.inQty ?: BigDecimal.ZERO | |||
| val outQty = lotLine.outQty ?: BigDecimal.ZERO | |||
| val remainingQty = inQty.minus(outQty) | |||
| if (remainingQty <= BigDecimal.ZERO) return@mapNotNull null | |||
| SameItemLotInfo( | |||
| lotNo = lotNo, | |||
| inventoryLotLineId = lotLine.id!!, | |||
| inventoryLotLineId = lotLine.id ?: return@mapNotNull null, | |||
| stockInLineId = lot.stockInLine?.id, | |||
| availableQty = remainingQty, | |||
| uom = uomDesc, | |||
| warehouseCode = whCode, | |||
| warehouseName = whName | |||
| warehouseCode = lotLine.warehouse?.code, | |||
| warehouseName = lotLine.warehouse?.name, | |||
| ) | |||
| else null | |||
| } | |||
| .toList() | |||
| val scannedLotNo = stockInLine.lotNo | |||
| ?: stockInLine.inventoryLot?.stockInLine?.lotNo | |||
| ?: throw IllegalStateException("Lot number not found for stockInLineId=${request.stockInLineId}") | |||
| return QrCodeAnalysisResponse( | |||
| itemId = request.itemId, | |||
| itemCode = item.code ?: "", | |||
| itemName = item.name ?: "", | |||
| scanned = ScannedLotInfo( | |||
| stockInLineId = request.stockInLineId, | |||
| lotNo = scannedLotNo, | |||
| inventoryLotLineId = scannedInventoryLotLine.id | |||
| ?: throw IllegalStateException("inventoryLotLineId missing on scanned lot line"), | |||
| warehouseCode = scannedInventoryLotLine.warehouse?.code, | |||
| warehouseName = scannedInventoryLotLine.warehouse?.name | |||
| ), | |||
| sameItemLots = sameItemLots | |||
| ) | |||
| } | |||
| } | |||
| return WorkbenchItemLotsResponse( | |||
| itemId = item.id ?: itemId, | |||
| itemCode = item.code ?: "", | |||
| itemName = item.name ?: "", | |||
| sameItemLots = sameItemLots, | |||
| ) | |||
| } | |||
| private fun analyzeQrCodeInternal( | |||
| request: QrCodeAnalysisRequest, | |||
| availableInOutOnly: Boolean, | |||
| ): QrCodeAnalysisResponse { | |||
| val stockInLine = stockInLineRepository.findById(request.stockInLineId).orElseThrow() | |||
| val scannedInventoryLotLine = | |||
| stockInLine.inventoryLotLine | |||
| ?: stockInLine.inventoryLot?.inventoryLotLines?.firstOrNull() | |||
| ?: throw IllegalStateException("No inventory lot line found for stockInLineId=${request.stockInLineId}") | |||
| val item = scannedInventoryLotLine.inventoryLot?.item | |||
| ?: throw IllegalStateException("Item not found for lot line id=${scannedInventoryLotLine.id}") | |||
| val sameItemLots = inventoryLotLineRepository | |||
| .findAllByInventoryLotItemIdAndStatus(request.itemId, InventoryLotLineStatus.AVAILABLE) | |||
| .asSequence() | |||
| .filter { it.id != scannedInventoryLotLine.id } | |||
| .mapNotNull { lotLine -> | |||
| val lot = lotLine.inventoryLot ?: return@mapNotNull null | |||
| val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null | |||
| val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: return@mapNotNull null | |||
| val whCode = lotLine.warehouse?.code | |||
| val whName = lotLine.warehouse?.name | |||
| val inQty = lotLine.inQty ?: BigDecimal.ZERO | |||
| val outQty = lotLine.outQty ?: BigDecimal.ZERO | |||
| val holdQty = lotLine.holdQty ?: BigDecimal.ZERO | |||
| val remainingQty = | |||
| if (availableInOutOnly) inQty.minus(outQty) else inQty.minus(outQty).minus(holdQty) | |||
| if (remainingQty > BigDecimal.ZERO) { | |||
| SameItemLotInfo( | |||
| lotNo = lotNo, | |||
| inventoryLotLineId = lotLine.id!!, | |||
| stockInLineId = lot.stockInLine?.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, | |||
| ) | |||
| } | |||
| } | |||
| @@ -1804,7 +1804,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| if (flushAfterSave) { | |||
| stockLedgerRepository.saveAndFlush(ledger) | |||
| println("${tracePrefix}Ledger created successfully for stockOutLineId=$solId") | |||
| //println("${tracePrefix}Ledger created successfully for stockOutLineId=$solId") | |||
| } else { | |||
| stockLedgerRepository.save(ledger) | |||
| } | |||
| @@ -1847,12 +1847,12 @@ open fun batchScan(request: com.ffii.fpsms.modules.stock.web.model.BatchScanRequ | |||
| try { | |||
| // 1) Bulk load all pick order lines | |||
| val pickOrderLineIds = request.lines.map { it.pickOrderLineId }.distinct() | |||
| println("Loading ${pickOrderLineIds.size} pick order lines...") | |||
| //println("Loading ${pickOrderLineIds.size} pick order lines...") | |||
| val pickOrderLines = pickOrderLineRepository.findAllById(pickOrderLineIds).associateBy { it.id } | |||
| // 2) Bulk load all inventory lot lines (if any) | |||
| val lotLineIds = request.lines.mapNotNull { it.inventoryLotLineId }.distinct() | |||
| println("Loading ${lotLineIds.size} inventory lot lines...") | |||
| // println("Loading ${lotLineIds.size} inventory lot lines...") | |||
| val inventoryLotLines = if (lotLineIds.isNotEmpty()) { | |||
| inventoryLotLineRepository.findAllById(lotLineIds).associateBy { it.id } | |||
| } else { | |||
| @@ -2223,4 +2223,119 @@ fun applyStockOutLineDelta( | |||
| return savedSol | |||
| } | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult { | |||
| if (request.lines.isEmpty()) return BatchStockOutResult(null, 0, 0) | |||
| val currentUserId = request.handler ?: SecurityUtils.getUser().orElseThrow().id ?: 0L | |||
| val lotLineIds = request.lines.map { it.inventoryLotLineId }.distinct() | |||
| // 1) 一次 preload lotLine + item + warehouse | |||
| val lotLines = inventoryLotLineRepository | |||
| .findAllByIdInAndDeletedFalseWithRefs(lotLineIds) | |||
| val lotLineMap = lotLines.associateBy { it.id!! } | |||
| // 2) 构造 header(每批一个,避免每笔新建) | |||
| val stockOutHeader = StockOut().apply { | |||
| this.type = request.type | |||
| this.completeDate = LocalDateTime.now() | |||
| this.handler = currentUserId | |||
| this.status = StockOutStatus.COMPLETE.status | |||
| } | |||
| val savedHeader = stockOutRepository.save(stockOutHeader) | |||
| val now = LocalDateTime.now() | |||
| val today = LocalDate.now() | |||
| val lotLinesToUpdate = mutableListOf<InventoryLotLine>() | |||
| val stockOutLinesToInsert = mutableListOf<StockOutLine>() | |||
| val ledgersToInsert = mutableListOf<StockLedger>() | |||
| var skipped = 0 | |||
| // 3) 内存校验 + 组装对象(不在循环里 findById / saveAndFlush) | |||
| request.lines.forEach { lineReq -> | |||
| val lotLine = lotLineMap[lineReq.inventoryLotLineId] | |||
| if (lotLine == null) { | |||
| skipped++ | |||
| return@forEach | |||
| } | |||
| val qtyBd = BigDecimal.valueOf(lineReq.qty) | |||
| if (qtyBd <= BigDecimal.ZERO) { | |||
| skipped++ | |||
| return@forEach | |||
| } | |||
| // 可用量校验(按你现有规则) | |||
| val inQty = lotLine.inQty ?: BigDecimal.ZERO | |||
| val outQty = lotLine.outQty ?: BigDecimal.ZERO | |||
| val holdQty = lotLine.holdQty ?: BigDecimal.ZERO | |||
| val available = inQty.subtract(outQty) // Expiry 通常吃剩余量,可视业务改成 -holdQty | |||
| if (qtyBd > available) { | |||
| skipped++ | |||
| return@forEach | |||
| } | |||
| // 更新 lot line | |||
| lotLine.outQty = outQty.add(qtyBd) | |||
| lotLine.holdQty = holdQty.subtract(qtyBd).coerceAtLeast(BigDecimal.ZERO) | |||
| lotLine.status = inventoryLotLineService.deriveInventoryLotLineStatus( | |||
| lotLine.status, lotLine.inQty, lotLine.outQty, lotLine.holdQty | |||
| ) | |||
| lotLinesToUpdate += lotLine | |||
| val item = lotLine.inventoryLot?.item ?: run { | |||
| skipped++ | |||
| return@forEach | |||
| } | |||
| // 组装 stock_out_line | |||
| val sol = StockOutLine().apply { | |||
| this.item = item | |||
| this.qty = lineReq.qty | |||
| this.stockOut = savedHeader | |||
| this.inventoryLotLine = lotLine | |||
| this.status = StockOutLineStatus.COMPLETE.status | |||
| this.pickTime = now | |||
| this.handledBy = currentUserId | |||
| this.type = request.type | |||
| } | |||
| stockOutLinesToInsert += sol | |||
| } | |||
| // 4) 批量写(关键) | |||
| inventoryLotLineRepository.saveAll(lotLinesToUpdate) | |||
| val savedLines = stockOutLineRepository.saveAll(stockOutLinesToInsert) | |||
| // 5) 批量组装 ledger(避免每笔 createStockLedgerForStockOut) | |||
| savedLines.forEach { sol -> | |||
| val item = sol.item ?: return@forEach | |||
| val inv = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return@forEach | |||
| val delta = BigDecimal.valueOf(sol.qty ?: 0.0) | |||
| val prevBalance = inv.onHandQty?.toDouble() ?: 0.0 | |||
| val newBalance = prevBalance - delta.toDouble() | |||
| ledgersToInsert += StockLedger().apply { | |||
| this.stockOutLine = sol | |||
| this.inventory = inv | |||
| this.inQty = null | |||
| this.outQty = delta.toDouble() | |||
| this.balance = newBalance | |||
| this.type = request.type | |||
| this.itemId = item.id | |||
| this.itemCode = item.code | |||
| this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | |||
| ?: inv.uom?.id | |||
| this.date = today | |||
| } | |||
| } | |||
| stockLedgerRepository.saveAll(ledgersToInsert) | |||
| return BatchStockOutResult( | |||
| stockOutId = savedHeader.id, | |||
| processedCount = savedLines.size, | |||
| skippedCount = skipped | |||
| ) | |||
| } | |||
| } | |||
| @@ -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 = "pick successful", | |||
| 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") | |||
| } | |||
| } | |||
| @@ -616,7 +616,7 @@ open class StockTakeRecordService( | |||
| val warehouse = ill.warehouse | |||
| val availableQty = (ill.inQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.outQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| //.subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| val stockTakeRecord = if (stockTakeId != null && inventoryLot?.id != null && warehouse?.id != null) { | |||
| stockTakeRecordsMap[Pair(inventoryLot.id, warehouse.id)] | |||
| @@ -770,7 +770,7 @@ open class StockTakeRecordService( | |||
| val availableQty = (ill.inQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.outQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| //.subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| availableQty.compareTo(BigDecimal.ZERO) > 0 || recordKeySet.contains(Pair(lotId, whId)) | |||
| } | |||
| @@ -873,7 +873,7 @@ open class StockTakeRecordService( | |||
| val warehouse = ill.warehouse | |||
| val availableQty = (ill.inQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.outQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| //.subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| InventoryLotDetailResponse( | |||
| id = ill.id ?: 0L, | |||
| @@ -972,7 +972,7 @@ open class StockTakeRecordService( | |||
| val warehouse = ill.warehouse | |||
| val availableQty = (ill.inQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.outQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| // .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| val inventoryLotLineId = ill.id | |||
| val stockTakeLine = | |||
| if (effectiveStockTakeId != null && inventoryLotLineId != null) { | |||
| @@ -1062,7 +1062,7 @@ open class StockTakeRecordService( | |||
| val warehouse = ill.warehouse | |||
| val availableQty = (ill.inQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.outQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| // .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| val stockTakeRecord = if (stockTakeId != null && inventoryLot?.id != null && warehouse?.id != null) { | |||
| stockTakeRecordsMap[Pair(inventoryLot.id, warehouse.id)] | |||
| @@ -1161,7 +1161,7 @@ open class StockTakeRecordService( | |||
| // 2. 计算 availableQty | |||
| val availableQty = (inventoryLotLine.inQty ?: BigDecimal.ZERO) | |||
| .subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO) | |||
| .subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) | |||
| //.subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) | |||
| // 3. 新建、更新預建記錄之第一次盤點、或第二次盤點 | |||
| val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) { | |||
| @@ -1303,12 +1303,8 @@ open class StockTakeRecordService( | |||
| println("Found ${inventoryLotLines.size} inventory lot lines") | |||
| // 4. 使用 stockTakeId 获取已创建的记录,建立映射以排除它们 | |||
| val existingRecordsMap = stockTakeRecordRepository.findAll() | |||
| .filter { | |||
| !it.deleted && | |||
| it.stockTake?.id == request.stockTakeId && | |||
| it.warehouse?.id in warehouseIds | |||
| } | |||
| val existingRecordsMap = stockTakeRecordRepository | |||
| .findAllByStockTakeIdAndWarehouseIdInAndDeletedIsFalse(request.stockTakeId, warehouseIds) | |||
| .associateBy { | |||
| Pair(it.inventoryLotId ?: 0L, it.warehouse?.id ?: 0L) | |||
| } | |||
| @@ -1350,7 +1346,7 @@ open class StockTakeRecordService( | |||
| // 计算 availableQty | |||
| val availableQty = (ill.inQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.outQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| // .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| // 使用 availableQty 作为 qty,badQty 为 0 | |||
| val qty = availableQty | |||
| @@ -1398,12 +1394,6 @@ open class StockTakeRecordService( | |||
| } | |||
| } | |||
| if (successCount > 0) { | |||
| val existingRecordsCount = stockTakeRecordRepository.findAll() | |||
| .filter { | |||
| !it.deleted && | |||
| it.stockTake?.id == request.stockTakeId | |||
| } | |||
| .count() | |||
| checkAndUpdateStockTakeStatus(request.stockTakeId, request.stockTakeSection) | |||
| } | |||
| println("batchSaveStockTakeRecords completed: success=$successCount, errors=$errorCount") | |||
| @@ -1585,15 +1575,11 @@ open fun batchSaveApproverStockTakeRecords( | |||
| ?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}") | |||
| val stockTakeRecords = stockTakeRecordRepository.findAll() | |||
| .filter { | |||
| !it.deleted && | |||
| it.stockTake?.id == request.stockTakeId && | |||
| it.stockTakeSection == request.stockTakeSection && | |||
| // 只处理已经有 picker 盘点记录的行(第一或第二次盘点任意一个非 null 即视为已盘点) | |||
| (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && | |||
| it.approverStockTakeQty == null // 只处理未审批的记录 | |||
| } | |||
| val stockTakeRecords = stockTakeRecordRepository | |||
| .findPendingApproverRecordsByStockTakeAndSection( | |||
| stockTakeId = request.stockTakeId, | |||
| stockTakeSection = request.stockTakeSection | |||
| ) | |||
| println("Found ${stockTakeRecords.size} records to process") | |||
| @@ -1711,14 +1697,12 @@ open fun batchSaveApproverStockTakeRecordsAll( | |||
| val roundStockTakeIds: Set<Long> = resolveRoundStockTakeIds(stockTake) | |||
| var stockTakeRecords = stockTakeRecordRepository.findAll() | |||
| var stockTakeRecords = stockTakeRecordRepository | |||
| .findAllByStockTakeIdInAndDeletedIsFalse(roundStockTakeIds) | |||
| .filter { | |||
| !it.deleted && | |||
| it.stockTake?.id != null && | |||
| it.stockTake!!.id!! in roundStockTakeIds && | |||
| // 只处理已经有 picker 盘点记录的行(第一或第二次盘点任意一个非 null 即视为已盘点) | |||
| (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && | |||
| it.approverStockTakeQty == null | |||
| // 只处理已经有 picker 盘点记录的行(第一或第二次盘点任意一个非 null 即视为已盘点) | |||
| (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && | |||
| it.approverStockTakeQty == null | |||
| } | |||
| val sectionParts = request.stockTakeSections | |||
| ?.split(",") | |||
| @@ -1855,6 +1839,111 @@ if (itemParts.isNotEmpty()) { | |||
| ) | |||
| } | |||
| open fun batchSaveApproverStockTakeRecordsByIds( | |||
| request: BatchSaveApproverStockTakeByIdsRequest | |||
| ): BatchSaveApproverStockTakeRecordResponse { | |||
| println("batchSaveApproverStockTakeRecordsByIds called for stockTakeId: ${request.stockTakeId}, ids=${request.recordIds.size}") | |||
| if (request.recordIds.isEmpty()) { | |||
| return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No record IDs provided")) | |||
| } | |||
| val user = userRepository.findById(request.approverId).orElse(null) | |||
| val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(request.stockTakeId) | |||
| ?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}") | |||
| val idSet = request.recordIds.toSet() | |||
| val stockTakeRecords = stockTakeRecordRepository.findAllById(request.recordIds) | |||
| .filter { | |||
| !it.deleted && | |||
| (it.id in idSet) && | |||
| (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && | |||
| it.approverStockTakeQty == null | |||
| } | |||
| println("Found ${stockTakeRecords.size} records to process by IDs") | |||
| if (stockTakeRecords.isEmpty()) { | |||
| return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No records found matching criteria")) | |||
| } | |||
| var successCount = 0 | |||
| var errorCount = 0 | |||
| val errors = mutableListOf<String>() | |||
| val processedStockTakes = mutableSetOf<Pair<Long, String>>() | |||
| stockTakeRecords.forEach { record -> | |||
| try { | |||
| val qty: BigDecimal | |||
| val badQty: BigDecimal | |||
| if (record.pickerSecondStockTakeQty != null && record.pickerSecondStockTakeQty!! > BigDecimal.ZERO) { | |||
| qty = record.pickerSecondStockTakeQty!! | |||
| badQty = record.pickerSecondBadQty ?: BigDecimal.ZERO | |||
| } else { | |||
| qty = record.pickerFirstStockTakeQty ?: BigDecimal.ZERO | |||
| badQty = record.pickerFirstBadQty ?: BigDecimal.ZERO | |||
| } | |||
| val bookQty = record.bookQty ?: BigDecimal.ZERO | |||
| val varianceQty = qty.subtract(bookQty) | |||
| record.apply { | |||
| this.approverId = request.approverId | |||
| this.approverName = user?.name | |||
| this.approverStockTakeQty = qty | |||
| this.approverBadQty = badQty | |||
| this.varianceQty = varianceQty | |||
| this.status = "completed" | |||
| this.approverTime = java.time.LocalDateTime.now() | |||
| this.lastSelect = if ( | |||
| record.pickerSecondStockTakeQty != null && | |||
| record.pickerSecondStockTakeQty!! > BigDecimal.ZERO | |||
| ) 2 else 1 | |||
| if (this.stockTakeEndTime == null) { | |||
| this.stockTakeEndTime = java.time.LocalDateTime.now() | |||
| } | |||
| } | |||
| stockTakeRecordRepository.save(record) | |||
| if (varianceQty != BigDecimal.ZERO) { | |||
| try { | |||
| applyVarianceAdjustment(record.stockTake ?: stockTake, record, qty, varianceQty, request.approverId) | |||
| } catch (e: Exception) { | |||
| logger.error("Failed to apply variance adjustment for record ${record.id}", e) | |||
| errorCount++ | |||
| errors.add("Record ${record.id}: ${e.message}") | |||
| return@forEach | |||
| } | |||
| } else { | |||
| completeStockTakeLineForApproverNoVariance(record.stockTake ?: stockTake, record, qty) | |||
| } | |||
| val stId = record.stockTake?.id | |||
| val section = record.stockTakeSection | |||
| if (stId != null && section != null) { | |||
| processedStockTakes.add(Pair(stId, section)) | |||
| } | |||
| successCount++ | |||
| } catch (e: Exception) { | |||
| errorCount++ | |||
| val errorMsg = "Error processing record ${record.id}: ${e.message}" | |||
| errors.add(errorMsg) | |||
| logger.error(errorMsg, e) | |||
| } | |||
| } | |||
| if (successCount > 0) { | |||
| processedStockTakes.forEach { (stId, section) -> | |||
| checkAndUpdateStockTakeStatus(stId, section) | |||
| } | |||
| } | |||
| println("batchSaveApproverStockTakeRecordsByIds completed: success=$successCount, errors=$errorCount") | |||
| return BatchSaveApproverStockTakeRecordResponse( | |||
| successCount = successCount, | |||
| errorCount = errorCount, | |||
| errors = errors | |||
| ) | |||
| } | |||
| /** | |||
| * stockTakeRecord 上存的是 inventory_lot.id(批次),不是 inventory_lot_line.id;用倉庫 + 批次找唯一庫存行。 | |||
| */ | |||
| @@ -1980,10 +2069,11 @@ private fun applyVarianceAdjustment( | |||
| // 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。 | |||
| val itemIdForLedger = inventoryLot.item?.id | |||
| ?: throw IllegalArgumentException("Item ID not found for stock take ledger") | |||
| val latestLedger = stockLedgerRepository.findLatestByItemId(itemIdForLedger).firstOrNull() | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) | |||
| val previousBalance = latestLedger?.balance | |||
| ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val newBalance = previousBalance - qtyToRemove.toDouble() | |||
| val stockLedger = StockLedger().apply { | |||
| this.inventory = inventory | |||
| this.itemId = inventoryLot.item?.id | |||
| @@ -1996,7 +2086,8 @@ private fun applyVarianceAdjustment( | |||
| this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id | |||
| this.date = LocalDate.now() | |||
| } | |||
| stockLedgerRepository.saveAndFlush(stockLedger) | |||
| stockLedgerRepository.save(stockLedger) | |||
| val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove) | |||
| val updateRequest = SaveInventoryLotLineRequest( | |||
| @@ -2058,10 +2149,11 @@ private fun applyVarianceAdjustment( | |||
| val itemIdForLedger = inventoryLot.item?.id | |||
| ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") | |||
| val latestLedger = stockLedgerRepository.findLatestByItemId(itemIdForLedger).firstOrNull() | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) | |||
| val previousBalance = latestLedger?.balance | |||
| ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val newBalance = previousBalance + plusQty.toDouble() | |||
| val stockLedger = StockLedger().apply { | |||
| this.inventory = inventory | |||
| this.itemId = inventoryLot.item?.id | |||
| @@ -2074,7 +2166,8 @@ private fun applyVarianceAdjustment( | |||
| this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id | |||
| this.date = LocalDate.now() | |||
| } | |||
| stockLedgerRepository.saveAndFlush(stockLedger) | |||
| stockLedgerRepository.save(stockLedger) | |||
| } | |||
| } | |||
| open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTakeRecord { | |||
| @@ -2146,7 +2239,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||
| val warehouse = ill.warehouse | |||
| val availableQty = (ill.inQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.outQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| // .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| val inventoryLotLineId = ill.id | |||
| val stockTakeLine = | |||
| if (effectiveStockTakeId != null && inventoryLotLineId != null) { | |||
| @@ -2238,7 +2331,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||
| val warehouse = ill.warehouse | |||
| val availableQty = (ill.inQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.outQty ?: BigDecimal.ZERO) | |||
| .subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| //.subtract(ill.holdQty ?: BigDecimal.ZERO) | |||
| val stockTakeRecord = if (stockTakeId != null && inventoryLot?.id != null && warehouse?.id != null) { | |||
| stockTakeRecordsMap[Pair(inventoryLot.id, warehouse.id)] | |||
| @@ -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.QrCodeAnalysisRequest | |||
| import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse | |||
| import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse | |||
| import org.springframework.http.HttpStatus | |||
| import org.springframework.web.server.ResponseStatusException | |||
| @@ -91,6 +92,34 @@ class InventoryLotLineController ( | |||
| inventoryLotLineService.printLabelForInventoryLotLine(request) | |||
| } | |||
| /** | |||
| * Workbench print endpoint: same printing behavior as `/print-label`, | |||
| * but always returns JSON so frontend won't fail on empty-body JSON parsing. | |||
| */ | |||
| @GetMapping("/workbench/print-label") | |||
| fun printLabelWorkbench(@ModelAttribute request: PrintLabelForInventoryLotLineRequest): MessageResponse { | |||
| return try { | |||
| inventoryLotLineService.printLabelForInventoryLotLine(request) | |||
| MessageResponse( | |||
| id = request.inventoryLotLineId, | |||
| name = "print_label", | |||
| code = "SUCCESS", | |||
| type = "workbench_print_label", | |||
| message = "Print job submitted", | |||
| errorPosition = null, | |||
| ) | |||
| } catch (e: Exception) { | |||
| MessageResponse( | |||
| id = request.inventoryLotLineId, | |||
| name = "print_label", | |||
| code = "ERROR", | |||
| type = "workbench_print_label", | |||
| message = e.message ?: "Print failed", | |||
| errorPosition = null, | |||
| ) | |||
| } | |||
| } | |||
| @PostMapping("/updateStatus") | |||
| fun updateInventoryLotLineStatus(@RequestBody request: UpdateInventoryLotLineStatusRequest): MessageResponse { | |||
| println("=== DEBUG: updateInventoryLotLineStatus Controller ===") | |||
| @@ -117,4 +146,16 @@ class InventoryLotLineController ( | |||
| fun analyzeQrCode(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { | |||
| return inventoryLotLineService.analyzeQrCode(request) | |||
| } | |||
| /** DO workbench label modal: same-item lots use pickable qty inQty - outQty (no hold). */ | |||
| @PostMapping("/workbench/analyze-qr-code") | |||
| fun analyzeQrCodeWorkbench(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { | |||
| return inventoryLotLineService.analyzeQrCodeWorkbench(request) | |||
| } | |||
| /** Workbench label modal fallback: load same-item available lots without stockInLineId. */ | |||
| @GetMapping("/workbench/available-lots-by-item/{itemId}") | |||
| fun listWorkbenchAvailableLotsByItem(@PathVariable itemId: Long): WorkbenchItemLotsResponse { | |||
| return inventoryLotLineService.listWorkbenchAvailableLotsByItem(itemId) | |||
| } | |||
| } | |||
| @@ -300,6 +300,28 @@ class StockTakeRecordController( | |||
| )) | |||
| } | |||
| } | |||
| @PostMapping("/batchSaveApproverStockTakeRecordsByIds") | |||
| fun batchSaveApproverStockTakeRecordsByIds( | |||
| @RequestBody request: BatchSaveApproverStockTakeByIdsRequest | |||
| ): ResponseEntity<Any> { | |||
| return try { | |||
| val result = stockOutRecordService.batchSaveApproverStockTakeRecordsByIds(request) | |||
| logger.info("Batch approver save by ids completed: success=${result.successCount}, errors=${result.errorCount}") | |||
| ResponseEntity.ok(result) | |||
| } catch (e: IllegalArgumentException) { | |||
| logger.warn("Validation error: ${e.message}") | |||
| ResponseEntity.badRequest().body(mapOf( | |||
| "error" to "VALIDATION_ERROR", | |||
| "message" to (e.message ?: "Validation failed") | |||
| )) | |||
| } catch (e: Exception) { | |||
| logger.error("Error batch saving approver stock take records by ids", e) | |||
| ResponseEntity.status(500).body(mapOf( | |||
| "error" to "INTERNAL_ERROR", | |||
| "message" to (e.message ?: "Failed to batch save approver stock take records by ids") | |||
| )) | |||
| } | |||
| } | |||
| @PostMapping("/updateStockTakeRecordStatusToNotMatch") | |||
| fun updateStockTakeRecordStatusToNotMatch( | |||
| @RequestParam stockTakeRecordId: Long | |||
| @@ -17,7 +17,7 @@ data class UpdateInventoryLotLineStatusRequest( | |||
| ) | |||
| data class QrCodeAnalysisRequest( | |||
| val itemId: Long, | |||
| val stockInLineId: Long | |||
| val stockInLineId: Long, | |||
| ) | |||
| data class ScannedLotInfo( | |||
| @@ -31,6 +31,7 @@ data class ScannedLotInfo( | |||
| data class SameItemLotInfo( | |||
| val lotNo: String, | |||
| val inventoryLotLineId: Long, | |||
| val stockInLineId: Long? = null, | |||
| val availableQty: BigDecimal, | |||
| val uom: String, | |||
| val warehouseCode: String? = null, | |||
| @@ -43,4 +44,11 @@ data class QrCodeAnalysisResponse( | |||
| val itemName: String, | |||
| val scanned: ScannedLotInfo, | |||
| val sameItemLots: List<SameItemLotInfo> | |||
| ) | |||
| data class WorkbenchItemLotsResponse( | |||
| val itemId: Long, | |||
| val itemCode: String, | |||
| val itemName: String, | |||
| val sameItemLots: List<SameItemLotInfo> | |||
| ) | |||
| @@ -102,3 +102,19 @@ data class BatchScanRequest( | |||
| val userId: Long, | |||
| val lines: List<BatchScanLineRequest> | |||
| ) | |||
| data class BatchStockOutRequest( | |||
| val type: String, // "Expiry" / "Miss" / "Bad" | |||
| val handler: Long?, | |||
| val lines: List<BatchStockOutLineRequest> | |||
| ) | |||
| data class BatchStockOutLineRequest( | |||
| val inventoryLotLineId: Long, | |||
| val qty: Double | |||
| ) | |||
| data class BatchStockOutResult( | |||
| val stockOutId: Long?, | |||
| val processedCount: Int, | |||
| val skippedCount: Int = 0 | |||
| ) | |||
| @@ -143,6 +143,12 @@ data class BatchSaveApproverStockTakeAllRequest( | |||
| val stockTakeSections: String? = null, | |||
| ) | |||
| data class BatchSaveApproverStockTakeByIdsRequest( | |||
| val stockTakeId: Long, | |||
| val approverId: Long, | |||
| val recordIds: List<Long>, | |||
| ) | |||
| data class BatchSaveApproverStockTakeRecordResponse( | |||
| val successCount: Int, | |||
| val errorCount: Int, | |||
| @@ -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); | |||
| @@ -0,0 +1,5 @@ | |||
| --liquibase formatted sql | |||
| --changeset Enson:20260427-01 | |||
| CREATE INDEX idx_ledger_item_deleted_date_id | |||
| ON stock_ledger (itemId, deleted, date, id); | |||