Sfoglia il codice sorgente

Merge branch 'master' into production

production
DESKTOP-064TTA1\Fai LUK 1 settimana fa
parent
commit
4cb8d0d6de
49 ha cambiato i file con 7894 aggiunte e 217 eliminazioni
  1. +77
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt
  2. +24
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrderRepository.kt
  3. +3
    -3
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  4. +404
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt
  5. +2690
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  6. +479
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
  7. +199
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchTruckRoutingSummaryService.kt
  8. +275
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt
  9. +11
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchScanPickRequest.kt
  10. +30
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt
  11. +24
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt
  12. +0
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/entity/jobOrderMatching.kt
  13. +101
    -43
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
  14. +556
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt
  15. +21
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchReleaseService.kt
  16. +19
    -3
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt
  17. +27
    -12
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt
  18. +6
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt
  19. +6
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/JobOrderActionRequest.kt
  20. +5
    -5
      src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
  21. +8
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickExecutionIssueRepository.kt
  22. +2
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt
  23. +360
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt
  24. +27
    -31
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  25. +4
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt
  26. +447
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt
  27. +92
    -3
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt
  28. +2
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ConsoPickOrderResponse.kt
  29. +2
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt
  30. +2
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  31. +1
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt
  32. +27
    -14
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  33. +12
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLIneRepository.kt
  34. +21
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt
  35. +128
    -57
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  36. +118
    -3
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt
  37. +702
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt
  38. +134
    -41
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  39. +636
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt
  40. +31
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/WorkbenchStockOutLinePickProgress.kt
  41. +41
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt
  42. +22
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt
  43. +9
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt
  44. +16
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt
  45. +6
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt
  46. +68
    -0
      src/main/resources/db/changelog/changes/20260408_01_Enson/01_alter_stock_take.sql
  47. +9
    -0
      src/main/resources/db/changelog/changes/20260417_01_Enson/01_alter_stock_take.sql
  48. +5
    -0
      src/main/resources/db/changelog/changes/20260420_01_Enson/03_alter_stock_take.sql
  49. +5
    -0
      src/main/resources/db/changelog/changes/20260427_01_Enson/01_alter_stock_take.sql

+ 77
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt Vedi File

@@ -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
}

+ 24
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrderRepository.kt Vedi File

@@ -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?


}

+ 3
- 3
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt Vedi File

@@ -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()



+ 404
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt Vedi File

@@ -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
)
)
}
}

+ 2690
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
File diff soppresso perché troppo grande
Vedi File


+ 479
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt Vedi File

@@ -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
}
}

+ 199
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchTruckRoutingSummaryService.kt Vedi File

@@ -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()
}
}
}

+ 275
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt Vedi File

@@ -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)
}

}

+ 11
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchScanPickRequest.kt Vedi File

@@ -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(),
)


+ 30
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt Vedi File

@@ -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,
)


+ 24
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt Vedi File

@@ -0,0 +1,24 @@
package com.ffii.fpsms.modules.deliveryOrder.web.models

import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime

data class WorkbenchTicketReleaseTableResponse(
val deliveryOrderPickOrderId: Long,
val storeId: String?,
val ticketNo: String?,
val loadingSequence: Int?,
val ticketStatus: String?,
val truckDepartureTime: LocalTime?,
val handledBy: Long?,
val ticketReleaseTime: LocalDateTime?,
val ticketCompleteDateTime: LocalDateTime?,
val truckLanceCode: String?,
val shopCode: String?,
val shopName: String?,
val requiredDeliveryDate: LocalDate?,
val handlerName: String?,
val numberOfFGItems: Int = 0,
val isActiveWorkbenchTicket: Boolean = false,
)

+ 0
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/entity/jobOrderMatching.kt Vedi File


+ 101
- 43
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt Vedi File

@@ -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


+ 556
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt Vedi File

@@ -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
)
}
}
}

+ 21
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchReleaseService.kt Vedi File

@@ -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)
}
}

+ 19
- 3
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt Vedi File

@@ -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()) {


+ 27
- 12
src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt Vedi File

@@ -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 {


+ 6
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt Vedi File

@@ -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(


+ 6
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/JobOrderActionRequest.kt Vedi File

@@ -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(


+ 5
- 5
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt Vedi File

@@ -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}")


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickExecutionIssueRepository.kt Vedi File

@@ -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(


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt Vedi File

@@ -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>



+ 360
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt Vedi File

@@ -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),
)
}

+ 27
- 31
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt Vedi File

@@ -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
)
}
}


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt Vedi File

@@ -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,


+ 447
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt Vedi File

@@ -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
)
}
}
}


+ 92
- 3
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt Vedi File

@@ -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?,


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ConsoPickOrderResponse.kt Vedi File

@@ -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?,


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt Vedi File

@@ -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,


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt Vedi File

@@ -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,


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt Vedi File

@@ -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?,


+ 27
- 14
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt Vedi File

@@ -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>
}

+ 12
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLIneRepository.kt Vedi File

@@ -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


+ 21
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt Vedi File

@@ -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


+ 128
- 57
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt Vedi File

@@ -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,
)
}
}

+ 118
- 3
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt Vedi File

@@ -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
)
}
}

+ 702
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt Vedi File

@@ -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")
}
}

+ 134
- 41
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Vedi File

@@ -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)]


+ 636
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt Vedi File

@@ -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
}
}

+ 31
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/WorkbenchStockOutLinePickProgress.kt Vedi File

@@ -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) }
}

+ 41
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt Vedi File

@@ -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)
}
}

+ 22
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt Vedi File

@@ -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


+ 9
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt Vedi File

@@ -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>
)

+ 16
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt Vedi File

@@ -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
)

+ 6
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt Vedi File

@@ -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,


+ 68
- 0
src/main/resources/db/changelog/changes/20260408_01_Enson/01_alter_stock_take.sql Vedi File

@@ -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`)
);




+ 9
- 0
src/main/resources/db/changelog/changes/20260417_01_Enson/01_alter_stock_take.sql Vedi File

@@ -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;



+ 5
- 0
src/main/resources/db/changelog/changes/20260420_01_Enson/03_alter_stock_take.sql Vedi File

@@ -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);

+ 5
- 0
src/main/resources/db/changelog/changes/20260427_01_Enson/01_alter_stock_take.sql Vedi File

@@ -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);

Caricamento…
Annulla
Salva