| @@ -54,4 +54,29 @@ fun findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||
| requiredDeliveryDate: LocalDate, | |||
| ticketStatus: List<DoPickOrderStatus> | |||
| ): 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( | |||
| SuggestedPickLotForPolRequest(pickOrderLines = lines) | |||
| ) | |||
| val saveSuggestedPickLots = suggestedPickLotService.saveAll(suggestions.suggestedList) | |||
| val insufficientCount = suggestions.suggestedList.count { it.suggestedLotLine == null } | |||
| if (insufficientCount > 0) { | |||
| @@ -1538,7 +1539,8 @@ open class DeliveryOrderService( | |||
| } | |||
| } | |||
| 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. | |||
| saveSuggestedPickLots.forEach { lot -> | |||
| @@ -1546,8 +1548,8 @@ open class DeliveryOrderService( | |||
| val polId = lot.pickOrderLine?.id | |||
| val illId = lot.suggestedLotLine?.id | |||
| 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) { | |||
| val line = StockOutLine().apply { | |||
| @@ -444,7 +444,7 @@ open class DoPickOrderService( | |||
| .mapValues { (_, entries) -> | |||
| entries.map { it.value } | |||
| .filter { it.unassigned > 0 } | |||
| .sortedByDescending { it.unassigned } | |||
| .sortedBy { it.truckLanceCode } | |||
| } | |||
| .filterValues { lanes -> lanes.any { it.unassigned > 0 } } | |||
| .toSortedMap(compareBy { it }) | |||
| @@ -5,8 +5,10 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import org.springframework.stereotype.Service | |||
| import java.time.Instant | |||
| import java.util.UUID | |||
| import java.sql.SQLException | |||
| import java.util.concurrent.ConcurrentHashMap | |||
| import java.util.concurrent.Executors | |||
| import java.util.concurrent.Semaphore | |||
| import java.util.concurrent.atomic.AtomicInteger | |||
| import kotlin.math.min | |||
| 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.deliveryOrder.entity.DoPickOrderRecordRepository | |||
| 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( | |||
| val jobId: String, | |||
| val total: Int, | |||
| @@ -51,6 +106,8 @@ class DoReleaseCoordinatorService( | |||
| ) { | |||
| private val poolSize = Runtime.getRuntime().availableProcessors() | |||
| 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 fun getDayOfWeekAbbr(date: LocalDate?): String? { | |||
| if (date == null) return null | |||
| @@ -460,8 +517,10 @@ class DoReleaseCoordinatorService( | |||
| jobs[jobId] = status | |||
| executor.submit { | |||
| batchReleaseConcurrencyGate.acquireUninterruptibly() | |||
| try { | |||
| println("📦 Starting batch release for ${ids.size} orders") | |||
| try { | |||
| println("Starting batch release for ${ids.size} orders (job $jobId)") | |||
| val sortedIds = getOrderedDeliveryOrderIds(ids) | |||
| println(" DEBUG: Got ${sortedIds.size} sorted orders") | |||
| @@ -478,18 +537,52 @@ class DoReleaseCoordinatorService( | |||
| println("⏭️ DO $id is already ${deliveryOrder.status?.value}, skipping") | |||
| 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) { | |||
| synchronized(status.failed) { | |||
| status.failed.add(id to (e.message ?: "Exception")) | |||
| } | |||
| 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 记录 | |||
| try { | |||
| val issueCategory = when { | |||
| @@ -538,7 +631,7 @@ class DoReleaseCoordinatorService( | |||
| grouped.forEach { (key, group) -> | |||
| try { | |||
| createMergedDoPickOrder(group) | |||
| println(" DEBUG: Created DoPickOrder for ${group.size} DOs") | |||
| println(" DEBUG: Merged/created DoPickOrder for ${group.size} DO(s)") | |||
| } catch (e: Exception) { | |||
| println("❌ Error creating DoPickOrder: ${e.message}") | |||
| e.printStackTrace() | |||
| @@ -553,97 +646,125 @@ class DoReleaseCoordinatorService( | |||
| 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 -> | |||
| val existingLines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(result.pickOrderId) | |||
| if (existingLines.isNotEmpty()) { | |||
| println("⚠️ WARNING: pick_order ${result.pickOrderId} already in do_pick_order_line, skipping") | |||
| return@forEach // 跳过这个 | |||
| return@forEach | |||
| } | |||
| // 先创建 DoPickOrderLine,然后检查库存问题 | |||
| val line = DoPickOrderLine().apply { | |||
| doPickOrderId = saved.id | |||
| doPickOrderId = target.id | |||
| pickOrderId = result.pickOrderId | |||
| doOrderId = result.deliveryOrderId | |||
| pickOrderCode = result.pickOrderCode | |||
| deliveryOrderCode = result.deliveryOrderCode | |||
| status = "pending" // 初始状态 | |||
| status = "pending" | |||
| } | |||
| doPickOrderLineRepository.save(line) | |||
| println(" DEBUG: Created DoPickOrderLine for pick order ${result.pickOrderId}") | |||
| } | |||
| // 现在检查整个 DoPickOrder 是否有库存问题 | |||
| val hasStockIssues = checkPickOrderHasStockIssues(saved.id!!) | |||
| val hasStockIssues = checkPickOrderHasStockIssues(target.id!!) | |||
| if (hasStockIssues) { | |||
| // 更新所有相关的 DoPickOrderLine 状态为 "issue" | |||
| val doPickOrderLines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(saved.id!!) | |||
| val doPickOrderLines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(target.id!!) | |||
| doPickOrderLines.forEach { line -> | |||
| line.status = "issue" | |||
| } | |||
| doPickOrderLineRepository.saveAll(doPickOrderLines) | |||
| println(" DEBUG: Updated ${doPickOrderLines.size} DoPickOrderLine records to 'issue' status") | |||
| } | |||
| println(" DEBUG: Created ${results.size} DoPickOrderLine records") | |||
| } | |||
| private fun checkPickOrderHasStockIssues(doPickOrderId: Long): Boolean { | |||
| @@ -1770,7 +1770,7 @@ private fun normalizeFloor(raw: String): String { | |||
| val num = cleaned.replace(Regex("[^0-9]"), "") | |||
| return if (num.isNotEmpty()) "${num}F" else cleaned | |||
| } | |||
| open fun getAllJoPickOrders(isDrink: Boolean?, floor: String?): List<AllJoPickOrderResponse> { | |||
| open fun getAllJoPickOrders(bomType: String?, floor: String?): List<AllJoPickOrderResponse> { | |||
| println("=== getAllJoPickOrders ===") | |||
| return try { | |||
| @@ -1879,8 +1879,12 @@ open fun getAllJoPickOrders(isDrink: Boolean?, floor: String?): List<AllJoPickOr | |||
| val bom = jobOrder.bom | |||
| // 按 isDrink 过滤:null 表示不过滤(全部) | |||
| if (isDrink != null && bom?.isDrink != isDrink) return@mapNotNull null | |||
| // 按 bom.type 过滤:null / blank 表示不过滤(全部) | |||
| val normalizedType = bomType?.trim()?.lowercase()?.takeIf { it.isNotBlank() } | |||
| if (normalizedType != null) { | |||
| val currentType = bom?.type?.trim()?.lowercase() | |||
| if (currentType != normalizedType) return@mapNotNull null | |||
| } | |||
| println("BOM found: ${bom?.id}") | |||
| val item = bom?.item | |||
| @@ -243,12 +243,12 @@ fun recordSecondScanIssue( | |||
| } | |||
| @GetMapping("/AllJoPickOrder") | |||
| fun getAllJoPickOrder( | |||
| @RequestParam(required = false) isDrink: Boolean?, | |||
| @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? | |||
| ): List<AllJoPickOrderResponse> { | |||
| return joPickOrderService.getAllJoPickOrders(isDrink, floor) | |||
| return joPickOrderService.getAllJoPickOrders(bomType, floor) | |||
| } | |||
| @GetMapping("/all-lots-hierarchical-by-pick-order/{pickOrderId}") | |||
| @@ -28,6 +28,8 @@ open class Bom : BaseEntity<Long>() { | |||
| @Column | |||
| open var allergicSubstances: Int? = null | |||
| @Column | |||
| open var type: String? = null | |||
| @Column | |||
| open var timeSequence: Int? = null | |||
| @Column | |||
| @@ -1407,11 +1407,15 @@ open class ProductProcessService( | |||
| ) | |||
| } | |||
| open fun getAllJoborderProductProcessInfo(isDrink: Boolean?): List<AllJoborderProductProcessInfoResponse> { | |||
| open fun getAllJoborderProductProcessInfo(bomType: String?): List<AllJoborderProductProcessInfoResponse> { | |||
| val productProcesses = productProcessRepository.findAllByDeletedIsFalse() | |||
| val productProcessIds = productProcesses.map { it.id } | |||
| val normalizedType = bomType?.trim()?.lowercase()?.takeIf { it.isNotBlank() } | |||
| val filteredProductProcesses = productProcesses.filter { process -> | |||
| if (normalizedType == null) return@filter true | |||
| process.bom?.type?.trim()?.lowercase() == normalizedType | |||
| } | |||
| return productProcesses.map { productProcesses -> | |||
| return filteredProductProcesses.map { productProcesses -> | |||
| val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcesses.id ?: 0L) | |||
| val jobOrder = jobOrderRepository.findById(productProcesses.jobOrder?.id ?: 0L).orElse(null) | |||
| val FinishedProductProcessLineCount = | |||
| @@ -1478,9 +1482,6 @@ open class ProductProcessService( | |||
| ) | |||
| }.filter { response -> | |||
| // 过滤掉已完成上架的 job order | |||
| if (isDrink != null && response.isDrink != isDrink) { | |||
| return@filter false | |||
| } | |||
| val jobOrder = jobOrderRepository.findById(response.jobOrderId ?: 0L).orElse(null) | |||
| val stockInLineStatus = jobOrder?.stockInLines?.firstOrNull()?.status | |||
| stockInLineStatus != "completed" | |||
| @@ -1504,7 +1505,7 @@ open class ProductProcessService( | |||
| jobOrderCode: String?, | |||
| bomIds: List<Long>?, | |||
| qcReady: Boolean?, | |||
| isDrink: Boolean?, | |||
| bomType: String?, | |||
| page: Int, | |||
| size: Int | |||
| ): JobOrderProductProcessPageResponse { | |||
| @@ -1514,7 +1515,7 @@ open class ProductProcessService( | |||
| val trimmedItemCode = itemCode?.trim()?.takeIf { it.isNotBlank() } | |||
| val trimmedJobOrderCode = jobOrderCode?.trim()?.takeIf { it.isNotBlank() } | |||
| // 1) 找出候选 jobOrder:用 date/itemCode/jobOrderCode/bomIds/isDrink 先把数据缩小到今天等范围 | |||
| // 1) 找出候选 jobOrder:用 date/itemCode/jobOrderCode/bomIds/type 先把数据缩小到今天等范围 | |||
| val candidateProcesses = if (date != null) { | |||
| productProcessRepository.findAllByDeletedIsFalseAndDate(date) | |||
| } else { | |||
| @@ -1523,9 +1524,10 @@ open class ProductProcessService( | |||
| val filteredCandidateProcesses = candidateProcesses.filter { p -> | |||
| if (p.jobOrder?.isHidden == true) return@filter false | |||
| if (isDrink != null) { | |||
| val bomIsDrink = p.bom?.isDrink | |||
| if (bomIsDrink != isDrink) return@filter false | |||
| val normalizedType = bomType?.trim()?.lowercase()?.takeIf { it.isNotBlank() } | |||
| if (normalizedType != null) { | |||
| val currentType = p.bom?.type?.trim()?.lowercase() | |||
| if (currentType != normalizedType) return@filter false | |||
| } | |||
| if (trimmedItemCode != null) { | |||
| val code = p.item?.code | |||
| @@ -194,9 +194,9 @@ class ProductProcessController( | |||
| @GetMapping("/Demo/Process/all") | |||
| fun demoprocessall( | |||
| @RequestParam(required = false) isDrink: Boolean? | |||
| @RequestParam(name = "type", required = false) bomType: String? | |||
| ): List<AllJoborderProductProcessInfoResponse> { | |||
| return productProcessService.getAllJoborderProductProcessInfo(isDrink) | |||
| return productProcessService.getAllJoborderProductProcessInfo(bomType) | |||
| } | |||
| @GetMapping("/Demo/Process/search") | |||
| @@ -206,7 +206,7 @@ class ProductProcessController( | |||
| @RequestParam(required = false) jobOrderCode: String?, | |||
| @RequestParam(required = false) bomIds: String?, | |||
| @RequestParam(required = false) qcReady: Boolean?, | |||
| @RequestParam(required = false) isDrink: Boolean?, | |||
| @RequestParam(name = "type", required = false) bomType: String?, | |||
| @RequestParam(defaultValue = "0") page: Int, | |||
| @RequestParam(defaultValue = "50") size: Int | |||
| ): JobOrderProductProcessPageResponse { | |||
| @@ -225,7 +225,7 @@ class ProductProcessController( | |||
| jobOrderCode = jobOrderCode, | |||
| bomIds = parsedBomIds, | |||
| qcReady = qcReady, | |||
| isDrink = isDrink, | |||
| bomType = bomType, | |||
| page = page, | |||
| size = size | |||
| ) | |||
| @@ -297,7 +297,7 @@ ORDER BY | |||
| } | |||
| /** | |||
| * V2:依盤點輪次 + 可選物料編號;僅輸出該輪已有 `stocktakerecord` 且 `status='completed'` 的列(未建立盤點紀錄的批號不列出)。 | |||
| * V2:依盤點輪次 + 可選物料編號;輸出該輪所有 `stocktakerecord`(`deleted=0`,不限 `status`;未建立盤點紀錄的批號不列出)。 | |||
| * 期初/累計區間取該輪紀錄之 MIN(`date`)~MAX(`date`)(與 V1 之 in/out 聚合邏輯一致,但以 rb 帶入)。 | |||
| * 「審核時間」欄位:`approverTime` 之本地日期時間字串;無則退回盤點日 `date`。 | |||
| */ | |||
| @@ -309,7 +309,6 @@ ORDER BY | |||
| SELECT COUNT(*) AS c FROM stocktakerecord s | |||
| WHERE s.deleted = 0 | |||
| AND s.stockTakeRoundId = :stockTakeRoundId | |||
| AND s.status = 'completed' | |||
| """.trimIndent() | |||
| val cntRow = jdbcDao.queryForList( | |||
| countSql, | |||
| @@ -335,7 +334,6 @@ WITH rb AS ( | |||
| FROM stocktakerecord s | |||
| WHERE s.deleted = 0 | |||
| AND s.stockTakeRoundId = :stockTakeRoundId | |||
| AND s.status = 'completed' | |||
| ), | |||
| latest_str AS ( | |||
| SELECT | |||
| @@ -350,7 +348,6 @@ latest_str AS ( | |||
| FROM stocktakerecord str | |||
| WHERE str.deleted = 0 | |||
| AND str.stockTakeRoundId = :stockTakeRoundId | |||
| AND str.status = 'completed' | |||
| ), | |||
| in_agg AS ( | |||
| SELECT | |||
| @@ -4,7 +4,13 @@ import com.ffii.core.entity.BaseEntity | |||
| import com.ffii.fpsms.modules.master.entity.UomConversion | |||
| import com.ffii.fpsms.modules.stock.enums.StockTakeLineStatus | |||
| 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.Size | |||
| import java.math.BigDecimal | |||
| @@ -44,4 +50,8 @@ open class StockTakeLine : BaseEntity<Long>() { | |||
| @Size(max = 500) | |||
| @Column(name = "remarks", length = 500) | |||
| 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> { | |||
| fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?; | |||
| fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?; | |||
| fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine? | |||
| fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse( | |||
| stockTakeIds: Collection<Long>, | |||
| inventoryLotLineIds: Collection<Long> | |||
| @@ -23,9 +23,10 @@ import org.springframework.stereotype.Service | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDateTime | |||
| import org.springframework.context.annotation.Lazy | |||
| import org.springframework.transaction.annotation.Transactional | |||
| @Service | |||
| class StockTakeService( | |||
| open class StockTakeService( | |||
| val stockTakeRepository: StockTakeRepository, | |||
| val warehouseRepository: WarehouseRepository, | |||
| val warehouseService: WarehouseService, | |||
| @@ -35,9 +36,11 @@ class StockTakeService( | |||
| @Lazy val itemUomService: ItemUomService, | |||
| val stockTakeLineService: StockTakeLineService, | |||
| 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 { | |||
| val prefix = "ST" | |||
| val midfix = CodeGenerator.DEFAULT_MIDFIX | |||
| @@ -76,10 +79,10 @@ class StockTakeService( | |||
| }.toString() | |||
| } | |||
| fun importExcel(workbook: Workbook?): String { | |||
| logger.info("--------- Start - Import Stock Take Excel -------"); | |||
| log.info("--------- Start - Import Stock Take Excel -------"); | |||
| if (workbook == null) { | |||
| logger.error("No Excel Import"); | |||
| log.error("No Excel Import"); | |||
| return "Import Excel failure"; | |||
| } | |||
| @@ -115,7 +118,7 @@ class StockTakeService( | |||
| stockTakeId = savedStockTake.id | |||
| ) | |||
| 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) { | |||
| val row = sheet.getRow(i) | |||
| @@ -126,7 +129,7 @@ class StockTakeService( | |||
| val code = getCellStringValue(row.getCell(COLUMN_WAREHOSE_INDEX)) | |||
| val zone = getCellStringValue(row.getCell(COLUMN_ZONE_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 warehouseCode = "$code-$zone-$slot" | |||
| @@ -145,26 +148,26 @@ class StockTakeService( | |||
| warehouseService.saveWarehouse(warehouseRequest) | |||
| } | |||
| } catch (e: Exception) { | |||
| logger.error("Import Error (Warehouse Error): ${e.message}") | |||
| log.error("Import Error (Warehouse Error): ${e.message}") | |||
| null | |||
| } ?: continue | |||
| // Item | |||
| 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; | |||
| itemsService.findByCode(itemCode) | |||
| } catch (e: Exception) { | |||
| logger.error("Import Error (Item Code Error): ${e.message}") | |||
| log.error("Import Error (Item Code Error): ${e.message}") | |||
| null | |||
| } ?: continue | |||
| // Stock Take Line (First Create) | |||
| 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() | |||
| } catch (e: Exception) { | |||
| logger.error("Import Error (Qty Error): ${e.message}") | |||
| log.error("Import Error (Qty Error): ${e.message}") | |||
| null | |||
| } ?: continue | |||
| @@ -218,7 +221,7 @@ class StockTakeService( | |||
| inventoryLotLineId = inventoryLotLine?.id | |||
| } | |||
| 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 | |||
| @@ -229,7 +232,7 @@ class StockTakeService( | |||
| status = StockTakeStatus.COMPLETED.value | |||
| } | |||
| saveStockTake(saveStockTakeReq) | |||
| logger.info("--------- End - Import Stock Take Excel -------") | |||
| log.info("--------- End - Import Stock Take Excel -------") | |||
| return "Import Excel success"; | |||
| } | |||
| @@ -237,7 +240,7 @@ class StockTakeService( | |||
| 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>() | |||
| @@ -253,6 +256,7 @@ class StockTakeService( | |||
| val batchPlanEnd = batchPlanStart.plusDays(1) | |||
| // 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4) | |||
| val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 | |||
| var placeholderStockTakerId: Long? = null | |||
| distinctSections.forEach { section -> | |||
| try { | |||
| val code = assignStockTakeNo() | |||
| @@ -268,14 +272,21 @@ class StockTakeService( | |||
| stockTakeRoundId = roundId | |||
| ) | |||
| val savedStockTake = saveStockTake(saveStockTakeReq) | |||
| if (placeholderStockTakerId == null) { | |||
| placeholderStockTakerId = stockTakeRecordService.resolvePlaceholderStockTakerId() | |||
| } | |||
| stockTakeRecordService.createPlaceholderStockTakeRecordsForStockTake( | |||
| savedStockTake, | |||
| placeholderStockTakerId!! | |||
| ) | |||
| 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) { | |||
| 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 | |||
| } | |||
| } | |||
| @@ -540,7 +540,7 @@ open class SuggestedPickLotService( | |||
| */ | |||
| val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(pickOrder.consoCode ?: "") | |||
| 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 | |||
| } | |||
| @@ -127,15 +127,16 @@ class StockTakeRecordController( | |||
| fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||
| @RequestParam stockTakeSection: String, | |||
| @RequestParam(required = false) stockTakeId: Long?, | |||
| @RequestParam(required = false) stockTakeRoundId: Long?, | |||
| @RequestParam(required = false, defaultValue = "0") pageNum: Int, | |||
| @RequestParam(required = false) pageSize: Int? | |||
| ): RecordsRes<InventoryLotDetailResponse> { | |||
| // If pageSize is null, use a large number to return all records | |||
| val actualPageSize = pageSize ?: Int.MAX_VALUE | |||
| return stockOutRecordService.getInventoryLotDetailsByStockTakeSectionNotMatch( | |||
| stockTakeSection, | |||
| stockTakeId, | |||
| pageNum, | |||
| stockTakeSection, | |||
| stockTakeId, | |||
| stockTakeRoundId, | |||
| pageNum, | |||
| actualPageSize | |||
| ) | |||
| } | |||
| @@ -148,14 +149,21 @@ class StockTakeRecordController( | |||
| } | |||
| @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") | |||
| fun saveStockTakeRecord( | |||
| @@ -82,6 +82,28 @@ data class SaveStockTakeRecordRequest( | |||
| // val stockTakerName: String, | |||
| 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( | |||
| val stockTakeRecordId: Long? = null, | |||
| 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; | |||
| @@ -0,0 +1,9 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset Enson:alter_bom_type | |||
| -- preconditions onFail:MARK_RAN | |||
| -- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='fpsmsdb' AND table_name='bom' AND column_name='type' | |||
| ALTER TABLE `fpsmsdb`.`bom` | |||
| Add COLUMN `type` varchar(255); | |||
| @@ -0,0 +1,15 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset Enson:update_bom_type_by_code_and_isDrink_20260408 | |||
| UPDATE `fpsmsdb`.`bom` | |||
| SET `type` = CASE | |||
| WHEN isDrink = 1 THEN 'Drink' | |||
| WHEN code IN ( | |||
| 'PP2255','PP2257','PP2258','PP2259','PP2261','PP2263','PP2264','PP2266', | |||
| 'PP2270','PP2275','PP2280','PP2281','PP2283', | |||
| 'PP2291','PP2295','PP2296','PP2297','PP2299','PP2302','PP2303','PP2304', | |||
| 'PP2305','PP2339','PP2340' | |||
| ) THEN 'Powder_Mixture' | |||
| ELSE 'Other' | |||
| END | |||
| WHERE `type` IS NULL OR `type` = ''; | |||