| @@ -54,4 +54,29 @@ fun findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| requiredDeliveryDate: LocalDate, | requiredDeliveryDate: LocalDate, | ||||
| ticketStatus: List<DoPickOrderStatus> | ticketStatus: List<DoPickOrderStatus> | ||||
| ): List<DoPickOrder> | ): List<DoPickOrder> | ||||
| /** | |||||
| * Batch release: existing ticket same shop, same floor ([storeId]), same delivery date, still active. | |||||
| * [storeId] null means default-truck / XF-style row (matches NULL store_id on entity). | |||||
| */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT d FROM DoPickOrder d | |||||
| WHERE d.deleted = false | |||||
| AND d.releaseType = 'batch' | |||||
| AND d.shopId = :shopId | |||||
| AND d.requiredDeliveryDate = :requiredDate | |||||
| AND d.ticketStatus IN :statuses | |||||
| AND ( | |||||
| (:storeId IS NULL AND d.storeId IS NULL) | |||||
| OR (d.storeId = :storeId) | |||||
| ) | |||||
| """ | |||||
| ) | |||||
| fun findMergeableBatchDoPickOrders( | |||||
| @Param("shopId") shopId: Long, | |||||
| @Param("storeId") storeId: String?, | |||||
| @Param("requiredDate") requiredDate: LocalDate, | |||||
| @Param("statuses") statuses: List<DoPickOrderStatus>, | |||||
| ): List<DoPickOrder> | |||||
| } | } | ||||
| @@ -1519,6 +1519,7 @@ open class DeliveryOrderService( | |||||
| val suggestions = suggestedPickLotService.suggestionForPickOrderLines( | val suggestions = suggestedPickLotService.suggestionForPickOrderLines( | ||||
| SuggestedPickLotForPolRequest(pickOrderLines = lines) | SuggestedPickLotForPolRequest(pickOrderLines = lines) | ||||
| ) | ) | ||||
| val saveSuggestedPickLots = suggestedPickLotService.saveAll(suggestions.suggestedList) | val saveSuggestedPickLots = suggestedPickLotService.saveAll(suggestions.suggestedList) | ||||
| val insufficientCount = suggestions.suggestedList.count { it.suggestedLotLine == null } | val insufficientCount = suggestions.suggestedList.count { it.suggestedLotLine == null } | ||||
| if (insufficientCount > 0) { | if (insufficientCount > 0) { | ||||
| @@ -1538,7 +1539,8 @@ open class DeliveryOrderService( | |||||
| } | } | ||||
| } | } | ||||
| inventoryLotLineRepository.saveAll(inventoryLotLines) | inventoryLotLineRepository.saveAll(inventoryLotLines) | ||||
| val pickOrderLineMap = lines.associateBy { it.id } | |||||
| val inventoryLotLineMap = inventoryLotLines.associateBy { it.id } | |||||
| // No-lot (insufficient stock) lines are created in suggestedPickLotService.saveAll → createStockOutLineForSuggestion; skip here to avoid duplicates. | // No-lot (insufficient stock) lines are created in suggestedPickLotService.saveAll → createStockOutLineForSuggestion; skip here to avoid duplicates. | ||||
| saveSuggestedPickLots.forEach { lot -> | saveSuggestedPickLots.forEach { lot -> | ||||
| @@ -1546,8 +1548,8 @@ open class DeliveryOrderService( | |||||
| val polId = lot.pickOrderLine?.id | val polId = lot.pickOrderLine?.id | ||||
| val illId = lot.suggestedLotLine?.id | val illId = lot.suggestedLotLine?.id | ||||
| if (polId != null) { | if (polId != null) { | ||||
| val pickOrderLine = pickOrderLineRepository.findById(polId).orElse(null) | |||||
| val inventoryLotLine = illId?.let { inventoryLotLineRepository.findById(it).orElse(null) } | |||||
| val pickOrderLine = pickOrderLineMap[polId] | |||||
| val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| if (pickOrderLine != null) { | if (pickOrderLine != null) { | ||||
| val line = StockOutLine().apply { | val line = StockOutLine().apply { | ||||
| @@ -444,7 +444,7 @@ open class DoPickOrderService( | |||||
| .mapValues { (_, entries) -> | .mapValues { (_, entries) -> | ||||
| entries.map { it.value } | entries.map { it.value } | ||||
| .filter { it.unassigned > 0 } | .filter { it.unassigned > 0 } | ||||
| .sortedByDescending { it.unassigned } | |||||
| .sortedBy { it.truckLanceCode } | |||||
| } | } | ||||
| .filterValues { lanes -> lanes.any { it.unassigned > 0 } } | .filterValues { lanes -> lanes.any { it.unassigned > 0 } } | ||||
| .toSortedMap(compareBy { it }) | .toSortedMap(compareBy { it }) | ||||
| @@ -5,8 +5,10 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.time.Instant | import java.time.Instant | ||||
| import java.util.UUID | import java.util.UUID | ||||
| import java.sql.SQLException | |||||
| import java.util.concurrent.ConcurrentHashMap | import java.util.concurrent.ConcurrentHashMap | ||||
| import java.util.concurrent.Executors | import java.util.concurrent.Executors | ||||
| import java.util.concurrent.Semaphore | |||||
| import java.util.concurrent.atomic.AtomicInteger | import java.util.concurrent.atomic.AtomicInteger | ||||
| import kotlin.math.min | import kotlin.math.min | ||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| @@ -27,6 +29,59 @@ import com.ffii.fpsms.modules.user.entity.UserRepository | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository | import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository | import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus | import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus | ||||
| import jakarta.persistence.OptimisticLockException | |||||
| import org.hibernate.StaleObjectStateException | |||||
| import org.springframework.orm.ObjectOptimisticLockingFailureException | |||||
| /** | |||||
| * Batch DO release: retry [RELEASE_RETRY_MAX] times when Hibernate optimistic locking fails | |||||
| * on shared [com.ffii.fpsms.modules.stock.entity.InventoryLotLine] rows (concurrent suggest/hold). | |||||
| * | |||||
| * **How to reproduce contention (manual / test ideas):** | |||||
| * - Run two batch releases in parallel (two browsers or two API clients) that share hot lot lines; or | |||||
| * - Release truck B then immediately truck C with DOs that suggest the same `inventory_lot_line`; or | |||||
| * - Stress: many DOs in one batch all touching the same few lot lines. | |||||
| * | |||||
| * **Multiple browser tabs:** each tab starts a separate async job. Without a gate, up to [pool] threads | |||||
| * run batch releases in parallel and hammer the same `inventory_lot_line` rows → optimistic locks + deadlocks. | |||||
| * [batchReleaseConcurrencyGate] serializes batch jobs (per JVM) so trucks are released one job at a time. | |||||
| */ | |||||
| private const val RELEASE_RETRY_MAX = 3 | |||||
| private fun isOptimisticLockFailure(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 | |||||
| } | |||||
| /** MySQL deadlock / SQLSTATE 40001 — safe to retry like optimistic lock. */ | |||||
| private fun isDeadlockFailure(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 // ER_LOCK_DEADLOCK | |||||
| } | |||||
| c = c.cause | |||||
| } | |||||
| return false | |||||
| } | |||||
| private fun isRetriableConcurrencyFailure(t: Throwable?): Boolean = | |||||
| isOptimisticLockFailure(t) || isDeadlockFailure(t) | |||||
| data class BatchReleaseJobStatus( | data class BatchReleaseJobStatus( | ||||
| val jobId: String, | val jobId: String, | ||||
| val total: Int, | val total: Int, | ||||
| @@ -51,6 +106,8 @@ class DoReleaseCoordinatorService( | |||||
| ) { | ) { | ||||
| private val poolSize = Runtime.getRuntime().availableProcessors() | private val poolSize = Runtime.getRuntime().availableProcessors() | ||||
| private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) | private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) | ||||
| /** Only one batch-release job runs at a time (multiple tabs queue here). */ | |||||
| private val batchReleaseConcurrencyGate = Semaphore(1, true) | |||||
| private val jobs = ConcurrentHashMap<String, BatchReleaseJobStatus>() | private val jobs = ConcurrentHashMap<String, BatchReleaseJobStatus>() | ||||
| private fun getDayOfWeekAbbr(date: LocalDate?): String? { | private fun getDayOfWeekAbbr(date: LocalDate?): String? { | ||||
| if (date == null) return null | if (date == null) return null | ||||
| @@ -460,8 +517,10 @@ class DoReleaseCoordinatorService( | |||||
| jobs[jobId] = status | jobs[jobId] = status | ||||
| executor.submit { | executor.submit { | ||||
| batchReleaseConcurrencyGate.acquireUninterruptibly() | |||||
| try { | try { | ||||
| println("📦 Starting batch release for ${ids.size} orders") | |||||
| try { | |||||
| println("Starting batch release for ${ids.size} orders (job $jobId)") | |||||
| val sortedIds = getOrderedDeliveryOrderIds(ids) | val sortedIds = getOrderedDeliveryOrderIds(ids) | ||||
| println(" DEBUG: Got ${sortedIds.size} sorted orders") | println(" DEBUG: Got ${sortedIds.size} sorted orders") | ||||
| @@ -478,18 +537,52 @@ class DoReleaseCoordinatorService( | |||||
| println("⏭️ DO $id is already ${deliveryOrder.status?.value}, skipping") | println("⏭️ DO $id is already ${deliveryOrder.status?.value}, skipping") | ||||
| return@forEach | return@forEach | ||||
| } | } | ||||
| val result = deliveryOrderService.releaseDeliveryOrderWithoutTicket( | |||||
| ReleaseDoRequest(id = id, userId = userId) | |||||
| ) | |||||
| releaseResults.add(result) | |||||
| status.success.incrementAndGet() | |||||
| println(" DO $id -> Success") | |||||
| var result: ReleaseDoResult? = null | |||||
| for (attempt in 1..RELEASE_RETRY_MAX) { | |||||
| try { | |||||
| result = deliveryOrderService.releaseDeliveryOrderWithoutTicket( | |||||
| ReleaseDoRequest(id = id, userId = userId) | |||||
| ) | |||||
| break | |||||
| } catch (e: Exception) { | |||||
| if (isRetriableConcurrencyFailure(e) && attempt < RELEASE_RETRY_MAX) { | |||||
| val kind = when { | |||||
| isDeadlockFailure(e) -> "deadlock/DB lock" | |||||
| else -> "optimistic lock" | |||||
| } | |||||
| println( | |||||
| "⚠️ DO $id $kind (attempt $attempt/$RELEASE_RETRY_MAX), retrying after short backoff..." | |||||
| ) | |||||
| try { | |||||
| Thread.sleep(50L * attempt) | |||||
| } catch (_: InterruptedException) { | |||||
| Thread.currentThread().interrupt() | |||||
| throw e | |||||
| } | |||||
| } else { | |||||
| throw e | |||||
| } | |||||
| } | |||||
| } | |||||
| if (result != null) { | |||||
| releaseResults.add(result) | |||||
| status.success.incrementAndGet() | |||||
| println(" DO $id -> Success") | |||||
| } else { | |||||
| throw IllegalStateException("DO $id release returned null after $RELEASE_RETRY_MAX attempts") | |||||
| } | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| synchronized(status.failed) { | synchronized(status.failed) { | ||||
| status.failed.add(id to (e.message ?: "Exception")) | status.failed.add(id to (e.message ?: "Exception")) | ||||
| } | } | ||||
| println("❌ DO $id skipped: ${e.message}") | println("❌ DO $id skipped: ${e.message}") | ||||
| // Transient concurrency: avoid creating one issue per DO line (noise) | |||||
| if (isRetriableConcurrencyFailure(e)) { | |||||
| println("⚠️ DO $id: skipping batch-release issues for transient concurrency failure") | |||||
| return@forEach | |||||
| } | |||||
| // 调用 PickExecutionIssueService 创建 issue 记录 | // 调用 PickExecutionIssueService 创建 issue 记录 | ||||
| try { | try { | ||||
| val issueCategory = when { | val issueCategory = when { | ||||
| @@ -538,7 +631,7 @@ class DoReleaseCoordinatorService( | |||||
| grouped.forEach { (key, group) -> | grouped.forEach { (key, group) -> | ||||
| try { | try { | ||||
| createMergedDoPickOrder(group) | createMergedDoPickOrder(group) | ||||
| println(" DEBUG: Created DoPickOrder for ${group.size} DOs") | |||||
| println(" DEBUG: Merged/created DoPickOrder for ${group.size} DO(s)") | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| println("❌ Error creating DoPickOrder: ${e.message}") | println("❌ Error creating DoPickOrder: ${e.message}") | ||||
| e.printStackTrace() | e.printStackTrace() | ||||
| @@ -553,97 +646,125 @@ class DoReleaseCoordinatorService( | |||||
| println(" Batch completed: ${status.success.get()} success, ${status.failed.size} failed") | println(" Batch completed: ${status.success.get()} success, ${status.failed.size} failed") | ||||
| } catch (e: Exception) { | |||||
| println("❌ Batch release exception: ${e.javaClass.simpleName} - ${e.message}") | |||||
| e.printStackTrace() | |||||
| } finally { | |||||
| status.running = false | |||||
| status.finishedAt = Instant.now().toEpochMilli() | |||||
| } | |||||
| } | |||||
| } catch (e: Exception) { | |||||
| println("❌ Batch release exception: ${e.javaClass.simpleName} - ${e.message}") | |||||
| e.printStackTrace() | |||||
| } finally { | |||||
| status.running = false | |||||
| status.finishedAt = Instant.now().toEpochMilli() | |||||
| } | |||||
| } finally { | |||||
| batchReleaseConcurrencyGate.release() | |||||
| } | |||||
| } | |||||
| return MessageResponse( | |||||
| id = null, code = "STARTED", name = null, type = null, | |||||
| message = "Batch release started", errorPosition = null, | |||||
| entity = mapOf("jobId" to jobId, "total" to ids.size) | |||||
| ) | |||||
| } | |||||
| return MessageResponse( | |||||
| id = null, code = "STARTED", name = null, type = null, | |||||
| message = "Batch release started", errorPosition = null, | |||||
| entity = mapOf("jobId" to jobId, "total" to ids.size) | |||||
| ) | |||||
| } | |||||
| // 替换第 627-653 行 | |||||
| private fun createMergedDoPickOrder(results: List<ReleaseDoResult>) { | |||||
| val first = results.first() | |||||
| val storeId: String? = when { | |||||
| first.usedDefaultTruck!=false -> null | |||||
| else -> when (first.preferredFloor) { | |||||
| "2F" -> "2/F" | |||||
| "4F" -> "4/F" | |||||
| else -> "2/F" | |||||
| } | |||||
| private fun newMergedDoPickOrderEntity(first: ReleaseDoResult, storeId: String?): DoPickOrder { | |||||
| return DoPickOrder( | |||||
| storeId = storeId, | |||||
| ticketNo = "TEMP-${System.currentTimeMillis()}", | |||||
| ticketStatus = DoPickOrderStatus.pending, | |||||
| truckId = first.truckId, | |||||
| pickOrderId = null, | |||||
| doOrderId = null, | |||||
| ticketReleaseTime = null, | |||||
| shopId = first.shopId, | |||||
| handlerName = null, | |||||
| handledBy = null, | |||||
| ticketCompleteDateTime = null, | |||||
| truckDepartureTime = first.truckDepartureTime, | |||||
| truckLanceCode = first.truckLanceCode, | |||||
| shopCode = first.shopCode, | |||||
| shopName = first.shopName, | |||||
| requiredDeliveryDate = first.estimatedArrivalDate, | |||||
| createdBy = null, | |||||
| modifiedBy = null, | |||||
| pickOrderCode = null, | |||||
| deliveryOrderCode = null, | |||||
| loadingSequence = first.loadingSequence, | |||||
| releaseType = "batch" | |||||
| ) | |||||
| } | } | ||||
| val doPickOrder = DoPickOrder( | |||||
| storeId = storeId, | |||||
| ticketNo = "TEMP-${System.currentTimeMillis()}", | |||||
| ticketStatus = DoPickOrderStatus.pending, | |||||
| truckId = first.truckId, | |||||
| pickOrderId = null, | |||||
| doOrderId = null, | |||||
| ticketReleaseTime = null, | |||||
| shopId = first.shopId, | |||||
| handlerName = null, | |||||
| handledBy = null, | |||||
| ticketCompleteDateTime = null, | |||||
| truckDepartureTime = first.truckDepartureTime, | |||||
| truckLanceCode = first.truckLanceCode, | |||||
| shopCode = first.shopCode, | |||||
| shopName = first.shopName, | |||||
| requiredDeliveryDate = first.estimatedArrivalDate, | |||||
| createdBy = null, | |||||
| modifiedBy = null, | |||||
| pickOrderCode = null, | |||||
| deliveryOrderCode = null, | |||||
| loadingSequence = first.loadingSequence, | |||||
| releaseType = "batch" | |||||
| ) | |||||
| // 直接使用 doPickOrderRepository.save() 而不是 doPickOrderService.save() | |||||
| val saved = doPickOrderRepository.save(doPickOrder) | |||||
| println(" DEBUG: Saved DoPickOrder - ID: ${saved.id}, Ticket: ${saved.ticketNo}") | |||||
| // 创建多条 DoPickOrderLine(每个 DO 一条) | |||||
| /** | |||||
| * If a batch ticket already exists for the same shop, floor ([storeId]), delivery date, and is still | |||||
| * pending or released, add new DO lines onto it instead of creating another do_pick_order. | |||||
| */ | |||||
| private fun createMergedDoPickOrder(results: List<ReleaseDoResult>) { | |||||
| val first = results.first() | |||||
| val storeId: String? = when { | |||||
| first.usedDefaultTruck != false -> null | |||||
| else -> when (first.preferredFloor) { | |||||
| "2F" -> "2/F" | |||||
| "4F" -> "4/F" | |||||
| else -> "2/F" | |||||
| } | |||||
| } | |||||
| val target: DoPickOrder = | |||||
| if (first.shopId != null && first.estimatedArrivalDate != null) { | |||||
| val candidates = doPickOrderRepository.findMergeableBatchDoPickOrders( | |||||
| shopId = first.shopId!!, | |||||
| storeId = storeId, | |||||
| requiredDate = first.estimatedArrivalDate!!, | |||||
| statuses = listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released), | |||||
| ) | |||||
| val existing = candidates.firstOrNull { c -> | |||||
| c.truckId == first.truckId && | |||||
| c.truckDepartureTime == first.truckDepartureTime && | |||||
| c.truckLanceCode == first.truckLanceCode | |||||
| } ?: candidates.minByOrNull { it.id ?: Long.MAX_VALUE } | |||||
| if (existing != null) { | |||||
| println( | |||||
| " DEBUG: Merging batch into existing DoPickOrder id=${existing.id}, ticket=${existing.ticketNo} " + | |||||
| "(shop=${first.shopId}, storeId=$storeId, date=${first.estimatedArrivalDate})" | |||||
| ) | |||||
| existing | |||||
| } else { | |||||
| doPickOrderRepository.save(newMergedDoPickOrderEntity(first, storeId)) | |||||
| } | |||||
| } else { | |||||
| doPickOrderRepository.save(newMergedDoPickOrderEntity(first, storeId)) | |||||
| } | |||||
| println(" DEBUG: Target DoPickOrder - ID: ${target.id}, Ticket: ${target.ticketNo}") | |||||
| results.forEach { result -> | results.forEach { result -> | ||||
| val existingLines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(result.pickOrderId) | val existingLines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(result.pickOrderId) | ||||
| if (existingLines.isNotEmpty()) { | if (existingLines.isNotEmpty()) { | ||||
| println("⚠️ WARNING: pick_order ${result.pickOrderId} already in do_pick_order_line, skipping") | println("⚠️ WARNING: pick_order ${result.pickOrderId} already in do_pick_order_line, skipping") | ||||
| return@forEach // 跳过这个 | |||||
| return@forEach | |||||
| } | } | ||||
| // 先创建 DoPickOrderLine,然后检查库存问题 | |||||
| val line = DoPickOrderLine().apply { | val line = DoPickOrderLine().apply { | ||||
| doPickOrderId = saved.id | |||||
| doPickOrderId = target.id | |||||
| pickOrderId = result.pickOrderId | pickOrderId = result.pickOrderId | ||||
| doOrderId = result.deliveryOrderId | doOrderId = result.deliveryOrderId | ||||
| pickOrderCode = result.pickOrderCode | pickOrderCode = result.pickOrderCode | ||||
| deliveryOrderCode = result.deliveryOrderCode | deliveryOrderCode = result.deliveryOrderCode | ||||
| status = "pending" // 初始状态 | |||||
| status = "pending" | |||||
| } | } | ||||
| doPickOrderLineRepository.save(line) | doPickOrderLineRepository.save(line) | ||||
| println(" DEBUG: Created DoPickOrderLine for pick order ${result.pickOrderId}") | println(" DEBUG: Created DoPickOrderLine for pick order ${result.pickOrderId}") | ||||
| } | } | ||||
| // 现在检查整个 DoPickOrder 是否有库存问题 | |||||
| val hasStockIssues = checkPickOrderHasStockIssues(saved.id!!) | |||||
| val hasStockIssues = checkPickOrderHasStockIssues(target.id!!) | |||||
| if (hasStockIssues) { | if (hasStockIssues) { | ||||
| // 更新所有相关的 DoPickOrderLine 状态为 "issue" | |||||
| val doPickOrderLines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(saved.id!!) | |||||
| val doPickOrderLines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(target.id!!) | |||||
| doPickOrderLines.forEach { line -> | doPickOrderLines.forEach { line -> | ||||
| line.status = "issue" | line.status = "issue" | ||||
| } | } | ||||
| doPickOrderLineRepository.saveAll(doPickOrderLines) | doPickOrderLineRepository.saveAll(doPickOrderLines) | ||||
| println(" DEBUG: Updated ${doPickOrderLines.size} DoPickOrderLine records to 'issue' status") | println(" DEBUG: Updated ${doPickOrderLines.size} DoPickOrderLine records to 'issue' status") | ||||
| } | } | ||||
| println(" DEBUG: Created ${results.size} DoPickOrderLine records") | println(" DEBUG: Created ${results.size} DoPickOrderLine records") | ||||
| } | } | ||||
| private fun checkPickOrderHasStockIssues(doPickOrderId: Long): Boolean { | private fun checkPickOrderHasStockIssues(doPickOrderId: Long): Boolean { | ||||
| @@ -297,7 +297,7 @@ ORDER BY | |||||
| } | } | ||||
| /** | /** | ||||
| * V2:依盤點輪次 + 可選物料編號;僅輸出該輪已有 `stocktakerecord` 且 `status='completed'` 的列(未建立盤點紀錄的批號不列出)。 | |||||
| * V2:依盤點輪次 + 可選物料編號;輸出該輪所有 `stocktakerecord`(`deleted=0`,不限 `status`;未建立盤點紀錄的批號不列出)。 | |||||
| * 期初/累計區間取該輪紀錄之 MIN(`date`)~MAX(`date`)(與 V1 之 in/out 聚合邏輯一致,但以 rb 帶入)。 | * 期初/累計區間取該輪紀錄之 MIN(`date`)~MAX(`date`)(與 V1 之 in/out 聚合邏輯一致,但以 rb 帶入)。 | ||||
| * 「審核時間」欄位:`approverTime` 之本地日期時間字串;無則退回盤點日 `date`。 | * 「審核時間」欄位:`approverTime` 之本地日期時間字串;無則退回盤點日 `date`。 | ||||
| */ | */ | ||||
| @@ -309,7 +309,6 @@ ORDER BY | |||||
| SELECT COUNT(*) AS c FROM stocktakerecord s | SELECT COUNT(*) AS c FROM stocktakerecord s | ||||
| WHERE s.deleted = 0 | WHERE s.deleted = 0 | ||||
| AND s.stockTakeRoundId = :stockTakeRoundId | AND s.stockTakeRoundId = :stockTakeRoundId | ||||
| AND s.status = 'completed' | |||||
| """.trimIndent() | """.trimIndent() | ||||
| val cntRow = jdbcDao.queryForList( | val cntRow = jdbcDao.queryForList( | ||||
| countSql, | countSql, | ||||
| @@ -335,7 +334,6 @@ WITH rb AS ( | |||||
| FROM stocktakerecord s | FROM stocktakerecord s | ||||
| WHERE s.deleted = 0 | WHERE s.deleted = 0 | ||||
| AND s.stockTakeRoundId = :stockTakeRoundId | AND s.stockTakeRoundId = :stockTakeRoundId | ||||
| AND s.status = 'completed' | |||||
| ), | ), | ||||
| latest_str AS ( | latest_str AS ( | ||||
| SELECT | SELECT | ||||
| @@ -350,7 +348,6 @@ latest_str AS ( | |||||
| FROM stocktakerecord str | FROM stocktakerecord str | ||||
| WHERE str.deleted = 0 | WHERE str.deleted = 0 | ||||
| AND str.stockTakeRoundId = :stockTakeRoundId | AND str.stockTakeRoundId = :stockTakeRoundId | ||||
| AND str.status = 'completed' | |||||
| ), | ), | ||||
| in_agg AS ( | in_agg AS ( | ||||
| SELECT | SELECT | ||||
| @@ -4,7 +4,13 @@ import com.ffii.core.entity.BaseEntity | |||||
| import com.ffii.fpsms.modules.master.entity.UomConversion | import com.ffii.fpsms.modules.master.entity.UomConversion | ||||
| import com.ffii.fpsms.modules.stock.enums.StockTakeLineStatus | import com.ffii.fpsms.modules.stock.enums.StockTakeLineStatus | ||||
| import com.ffii.fpsms.modules.stock.enums.StockTakeLineStatusConverter | import com.ffii.fpsms.modules.stock.enums.StockTakeLineStatusConverter | ||||
| import jakarta.persistence.* | |||||
| import jakarta.persistence.Entity | |||||
| import jakarta.persistence.FetchType | |||||
| import jakarta.persistence.JoinColumn | |||||
| import jakarta.persistence.ManyToOne | |||||
| import jakarta.persistence.Table | |||||
| import jakarta.persistence.Convert | |||||
| import jakarta.persistence.Column | |||||
| import jakarta.validation.constraints.NotNull | import jakarta.validation.constraints.NotNull | ||||
| import jakarta.validation.constraints.Size | import jakarta.validation.constraints.Size | ||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| @@ -44,4 +50,8 @@ open class StockTakeLine : BaseEntity<Long>() { | |||||
| @Size(max = 500) | @Size(max = 500) | ||||
| @Column(name = "remarks", length = 500) | @Column(name = "remarks", length = 500) | ||||
| open var remarks: String? = null | open var remarks: String? = null | ||||
| @ManyToOne(fetch = FetchType.LAZY) | |||||
| @JoinColumn(name = "stockTakeRecordId") | |||||
| open var stockTakeRecord: StockTakeRecord? = null | |||||
| } | } | ||||
| @@ -8,6 +8,7 @@ import java.io.Serializable | |||||
| interface StockTakeLineRepository : AbstractRepository<StockTakeLine, Long> { | interface StockTakeLineRepository : AbstractRepository<StockTakeLine, Long> { | ||||
| fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?; | fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?; | ||||
| fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?; | fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?; | ||||
| fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine? | |||||
| fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse( | fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse( | ||||
| stockTakeIds: Collection<Long>, | stockTakeIds: Collection<Long>, | ||||
| inventoryLotLineIds: Collection<Long> | inventoryLotLineIds: Collection<Long> | ||||
| @@ -23,9 +23,10 @@ import org.springframework.stereotype.Service | |||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import org.springframework.context.annotation.Lazy | import org.springframework.context.annotation.Lazy | ||||
| import org.springframework.transaction.annotation.Transactional | |||||
| @Service | @Service | ||||
| class StockTakeService( | |||||
| open class StockTakeService( | |||||
| val stockTakeRepository: StockTakeRepository, | val stockTakeRepository: StockTakeRepository, | ||||
| val warehouseRepository: WarehouseRepository, | val warehouseRepository: WarehouseRepository, | ||||
| val warehouseService: WarehouseService, | val warehouseService: WarehouseService, | ||||
| @@ -35,9 +36,11 @@ class StockTakeService( | |||||
| @Lazy val itemUomService: ItemUomService, | @Lazy val itemUomService: ItemUomService, | ||||
| val stockTakeLineService: StockTakeLineService, | val stockTakeLineService: StockTakeLineService, | ||||
| val inventoryLotLineRepository: InventoryLotLineRepository, | val inventoryLotLineRepository: InventoryLotLineRepository, | ||||
| val stockTakeRecordService: StockTakeRecordService, | |||||
| ) { | ) { | ||||
| val logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | |||||
| companion object { | |||||
| private val log = LoggerFactory.getLogger(StockTakeService::class.java) | |||||
| } | |||||
| fun assignStockTakeNo(): String { | fun assignStockTakeNo(): String { | ||||
| val prefix = "ST" | val prefix = "ST" | ||||
| val midfix = CodeGenerator.DEFAULT_MIDFIX | val midfix = CodeGenerator.DEFAULT_MIDFIX | ||||
| @@ -76,10 +79,10 @@ class StockTakeService( | |||||
| }.toString() | }.toString() | ||||
| } | } | ||||
| fun importExcel(workbook: Workbook?): String { | fun importExcel(workbook: Workbook?): String { | ||||
| logger.info("--------- Start - Import Stock Take Excel -------"); | |||||
| log.info("--------- Start - Import Stock Take Excel -------"); | |||||
| if (workbook == null) { | if (workbook == null) { | ||||
| logger.error("No Excel Import"); | |||||
| log.error("No Excel Import"); | |||||
| return "Import Excel failure"; | return "Import Excel failure"; | ||||
| } | } | ||||
| @@ -115,7 +118,7 @@ class StockTakeService( | |||||
| stockTakeId = savedStockTake.id | stockTakeId = savedStockTake.id | ||||
| ) | ) | ||||
| val savedStockIn = stockInService.create(saveStockInReq) | val savedStockIn = stockInService.create(saveStockInReq) | ||||
| logger.info("Last row: ${sheet.lastRowNum}"); | |||||
| log.info("Last row: ${sheet.lastRowNum}"); | |||||
| for (i in START_ROW_INDEX ..< sheet.lastRowNum) { | for (i in START_ROW_INDEX ..< sheet.lastRowNum) { | ||||
| val row = sheet.getRow(i) | val row = sheet.getRow(i) | ||||
| @@ -126,7 +129,7 @@ class StockTakeService( | |||||
| val code = getCellStringValue(row.getCell(COLUMN_WAREHOSE_INDEX)) | val code = getCellStringValue(row.getCell(COLUMN_WAREHOSE_INDEX)) | ||||
| val zone = getCellStringValue(row.getCell(COLUMN_ZONE_INDEX)) | val zone = getCellStringValue(row.getCell(COLUMN_ZONE_INDEX)) | ||||
| val slot = getCellStringValue(row.getCell(COLUMN_SLOT_INDEX)) | val slot = getCellStringValue(row.getCell(COLUMN_SLOT_INDEX)) | ||||
| // logger.info("Warehouse code - zone - slot: ${row.getCell(COLUMN_WAREHOSE_INDEX).cellType} - ${row.getCell(COLUMN_ZONE_INDEX).cellType} - ${row.getCell(COLUMN_SLOT_INDEX).cellType}") | |||||
| // log.info("Warehouse code - zone - slot: ${row.getCell(COLUMN_WAREHOSE_INDEX).cellType} - ${row.getCell(COLUMN_ZONE_INDEX).cellType} - ${row.getCell(COLUMN_SLOT_INDEX).cellType}") | |||||
| val defaultCapacity = BigDecimal(10000) | val defaultCapacity = BigDecimal(10000) | ||||
| val warehouseCode = "$code-$zone-$slot" | val warehouseCode = "$code-$zone-$slot" | ||||
| @@ -145,26 +148,26 @@ class StockTakeService( | |||||
| warehouseService.saveWarehouse(warehouseRequest) | warehouseService.saveWarehouse(warehouseRequest) | ||||
| } | } | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| logger.error("Import Error (Warehouse Error): ${e.message}") | |||||
| log.error("Import Error (Warehouse Error): ${e.message}") | |||||
| null | null | ||||
| } ?: continue | } ?: continue | ||||
| // Item | // Item | ||||
| val item = try { | val item = try { | ||||
| // logger.info("Item Type: ${row.getCell(COLUMN_ITEM_CODE_INDEX).cellType}") | |||||
| // log.info("Item Type: ${row.getCell(COLUMN_ITEM_CODE_INDEX).cellType}") | |||||
| val itemCode = row.getCell(COLUMN_ITEM_CODE_INDEX).stringCellValue; | val itemCode = row.getCell(COLUMN_ITEM_CODE_INDEX).stringCellValue; | ||||
| itemsService.findByCode(itemCode) | itemsService.findByCode(itemCode) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| logger.error("Import Error (Item Code Error): ${e.message}") | |||||
| log.error("Import Error (Item Code Error): ${e.message}") | |||||
| null | null | ||||
| } ?: continue | } ?: continue | ||||
| // Stock Take Line (First Create) | // Stock Take Line (First Create) | ||||
| val qty = try { | val qty = try { | ||||
| // logger.info("Qty Type: ${row.getCell(COLUMN_QTY_INDEX).cellType}") | |||||
| // log.info("Qty Type: ${row.getCell(COLUMN_QTY_INDEX).cellType}") | |||||
| row.getCell(COLUMN_QTY_INDEX).numericCellValue.toBigDecimal() | row.getCell(COLUMN_QTY_INDEX).numericCellValue.toBigDecimal() | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| logger.error("Import Error (Qty Error): ${e.message}") | |||||
| log.error("Import Error (Qty Error): ${e.message}") | |||||
| null | null | ||||
| } ?: continue | } ?: continue | ||||
| @@ -218,7 +221,7 @@ class StockTakeService( | |||||
| inventoryLotLineId = inventoryLotLine?.id | inventoryLotLineId = inventoryLotLine?.id | ||||
| } | } | ||||
| stockTakeLineService.saveStockTakeLine(saveStockTakeLineReq) | stockTakeLineService.saveStockTakeLine(saveStockTakeLineReq) | ||||
| logger.info("[Stock Take]: Saved item '${item.name}' to warehouse '${warehouse.code}'") | |||||
| log.info("[Stock Take]: Saved item '${item.name}' to warehouse '${warehouse.code}'") | |||||
| } | } | ||||
| // End of Import | // End of Import | ||||
| @@ -229,7 +232,7 @@ class StockTakeService( | |||||
| status = StockTakeStatus.COMPLETED.value | status = StockTakeStatus.COMPLETED.value | ||||
| } | } | ||||
| saveStockTake(saveStockTakeReq) | saveStockTake(saveStockTakeReq) | ||||
| logger.info("--------- End - Import Stock Take Excel -------") | |||||
| log.info("--------- End - Import Stock Take Excel -------") | |||||
| return "Import Excel success"; | return "Import Excel success"; | ||||
| } | } | ||||
| @@ -237,7 +240,7 @@ class StockTakeService( | |||||
| fun createStockTakeForSections(): Map<String, String> { | fun createStockTakeForSections(): Map<String, String> { | ||||
| logger.info("--------- Start - Create Stock Take for Sections -------") | |||||
| log.info("--------- Start - Create Stock Take for Sections -------") | |||||
| val result = mutableMapOf<String, String>() | val result = mutableMapOf<String, String>() | ||||
| @@ -253,6 +256,7 @@ class StockTakeService( | |||||
| val batchPlanEnd = batchPlanStart.plusDays(1) | val batchPlanEnd = batchPlanStart.plusDays(1) | ||||
| // 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4) | // 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4) | ||||
| val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 | val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 | ||||
| var placeholderStockTakerId: Long? = null | |||||
| distinctSections.forEach { section -> | distinctSections.forEach { section -> | ||||
| try { | try { | ||||
| val code = assignStockTakeNo() | val code = assignStockTakeNo() | ||||
| @@ -268,14 +272,21 @@ class StockTakeService( | |||||
| stockTakeRoundId = roundId | stockTakeRoundId = roundId | ||||
| ) | ) | ||||
| val savedStockTake = saveStockTake(saveStockTakeReq) | val savedStockTake = saveStockTake(saveStockTakeReq) | ||||
| if (placeholderStockTakerId == null) { | |||||
| placeholderStockTakerId = stockTakeRecordService.resolvePlaceholderStockTakerId() | |||||
| } | |||||
| stockTakeRecordService.createPlaceholderStockTakeRecordsForStockTake( | |||||
| savedStockTake, | |||||
| placeholderStockTakerId!! | |||||
| ) | |||||
| result[section] = "Created: ${savedStockTake.code}" | result[section] = "Created: ${savedStockTake.code}" | ||||
| logger.info("Created stock take for section $section: ${savedStockTake.code}, roundId=$roundId") | |||||
| log.info("Created stock take for section $section: ${savedStockTake.code}, roundId=$roundId") | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| result[section] = "Error: ${e.message}" | result[section] = "Error: ${e.message}" | ||||
| logger.error("Error creating stock take for section $section: ${e.message}") | |||||
| log.error("Error creating stock take for section $section: ${e.message}") | |||||
| } | } | ||||
| } | } | ||||
| logger.info("--------- End - Create Stock Take for Sections -------") | |||||
| log.info("--------- End - Create Stock Take for Sections -------") | |||||
| return result | return result | ||||
| } | } | ||||
| } | } | ||||
| @@ -540,7 +540,7 @@ open class SuggestedPickLotService( | |||||
| */ | */ | ||||
| val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(pickOrder.consoCode ?: "") | val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(pickOrder.consoCode ?: "") | ||||
| if (stockOut == null) { | if (stockOut == null) { | ||||
| println("⚠️ StockOut not found for consoCode=${pickOrder.consoCode}, skip creating stockOutLine") | |||||
| println(" StockOut not found for consoCode=${pickOrder.consoCode}, skip creating stockOutLine") | |||||
| return null | return null | ||||
| } | } | ||||
| @@ -127,15 +127,16 @@ class StockTakeRecordController( | |||||
| fun getInventoryLotDetailsByStockTakeSectionNotMatch( | fun getInventoryLotDetailsByStockTakeSectionNotMatch( | ||||
| @RequestParam stockTakeSection: String, | @RequestParam stockTakeSection: String, | ||||
| @RequestParam(required = false) stockTakeId: Long?, | @RequestParam(required = false) stockTakeId: Long?, | ||||
| @RequestParam(required = false) stockTakeRoundId: Long?, | |||||
| @RequestParam(required = false, defaultValue = "0") pageNum: Int, | @RequestParam(required = false, defaultValue = "0") pageNum: Int, | ||||
| @RequestParam(required = false) pageSize: Int? | @RequestParam(required = false) pageSize: Int? | ||||
| ): RecordsRes<InventoryLotDetailResponse> { | ): RecordsRes<InventoryLotDetailResponse> { | ||||
| // If pageSize is null, use a large number to return all records | |||||
| val actualPageSize = pageSize ?: Int.MAX_VALUE | val actualPageSize = pageSize ?: Int.MAX_VALUE | ||||
| return stockOutRecordService.getInventoryLotDetailsByStockTakeSectionNotMatch( | return stockOutRecordService.getInventoryLotDetailsByStockTakeSectionNotMatch( | ||||
| stockTakeSection, | |||||
| stockTakeId, | |||||
| pageNum, | |||||
| stockTakeSection, | |||||
| stockTakeId, | |||||
| stockTakeRoundId, | |||||
| pageNum, | |||||
| actualPageSize | actualPageSize | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -148,14 +149,21 @@ class StockTakeRecordController( | |||||
| } | } | ||||
| @GetMapping("/inventoryLotDetailsBySection") | @GetMapping("/inventoryLotDetailsBySection") | ||||
| fun getInventoryLotDetailsByStockTakeSection( | |||||
| @RequestParam stockTakeSection: String, | |||||
| @RequestParam(required = false) stockTakeId: Long?, | |||||
| @RequestParam(required = false, defaultValue = "0") pageNum: Int, | |||||
| @RequestParam(required = false, defaultValue = "10") pageSize: Int | |||||
| ): RecordsRes<InventoryLotDetailResponse> { | |||||
| return stockOutRecordService.getInventoryLotDetailsByStockTakeSection(stockTakeSection, stockTakeId, pageNum, pageSize) | |||||
| } | |||||
| fun getInventoryLotDetailsByStockTakeSection( | |||||
| @RequestParam stockTakeSection: String, | |||||
| @RequestParam(required = false) stockTakeId: Long?, | |||||
| @RequestParam(required = false) stockTakeRoundId: Long?, | |||||
| @RequestParam(required = false, defaultValue = "0") pageNum: Int, | |||||
| @RequestParam(required = false, defaultValue = "10") pageSize: Int | |||||
| ): RecordsRes<InventoryLotDetailResponse> { | |||||
| return stockOutRecordService.getInventoryLotDetailsByStockTakeSection( | |||||
| stockTakeSection, | |||||
| stockTakeId, | |||||
| stockTakeRoundId, | |||||
| pageNum, | |||||
| pageSize | |||||
| ) | |||||
| } | |||||
| @PostMapping("/saveStockTakeRecord") | @PostMapping("/saveStockTakeRecord") | ||||
| fun saveStockTakeRecord( | fun saveStockTakeRecord( | ||||
| @@ -82,6 +82,28 @@ data class SaveStockTakeRecordRequest( | |||||
| // val stockTakerName: String, | // val stockTakerName: String, | ||||
| val remark: String? = null | val remark: String? = null | ||||
| ) | ) | ||||
| /** | |||||
| * 盤點員單筆儲存成功回傳。勿直接回傳 [StockTakeRecord] Entity,否則 Jackson 序列化 warehouse/stockTake 等關聯時 | |||||
| * 易因 Hibernate 代理/Session 已關閉導致 HTTP 500(controller 的 try/catch 無法攔截序列化階段錯誤)。 | |||||
| */ | |||||
| data class SaveStockTakeRecordResponse( | |||||
| val id: Long?, | |||||
| val version: Int?, | |||||
| val itemId: Long?, | |||||
| val lotId: Long?, | |||||
| val inventoryLotId: Long?, | |||||
| val warehouseId: Long?, | |||||
| val stockTakeId: Long?, | |||||
| val stockTakeRoundId: Long?, | |||||
| val status: String?, | |||||
| val bookQty: BigDecimal?, | |||||
| val varianceQty: BigDecimal?, | |||||
| val pickerFirstStockTakeQty: BigDecimal?, | |||||
| val pickerFirstBadQty: BigDecimal?, | |||||
| val pickerSecondStockTakeQty: BigDecimal?, | |||||
| val pickerSecondBadQty: BigDecimal?, | |||||
| ) | |||||
| data class SaveApproverStockTakeRecordRequest( | data class SaveApproverStockTakeRecordRequest( | ||||
| val stockTakeRecordId: Long? = null, | val stockTakeRecordId: Long? = null, | ||||
| val qty: BigDecimal, | val qty: BigDecimal, | ||||
| @@ -0,0 +1,8 @@ | |||||
| -- liquibase formatted sql | |||||
| -- changeset Enson:alter_stock_take_line | |||||
| ALTER TABLE `fpsmsdb`.`stock_take_line` | |||||
| Add COLUMN `stockTakeRecordId` INT; | |||||