From 5954b9588d0870ba7d9e755674efe23442f73bf7 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 8 Apr 2026 17:19:49 +0800 Subject: [PATCH 1/2] update --- .../entity/DoPickOrderRepository.kt | 25 + .../service/DeliveryOrderService.kt | 8 +- .../service/DoPickOrderService.kt | 2 +- .../service/DoReleaseCoordinatorService.kt | 271 ++++-- .../service/StockTakeVarianceReportService.kt | 5 +- .../modules/stock/entity/StockTakeLine.kt | 12 +- .../stock/entity/StockTakeLineRepository.kt | 1 + .../stock/service/StockTakeRecordService.kt | 795 +++++++++++++----- .../modules/stock/service/StockTakeService.kt | 47 +- .../stock/service/SuggestedPickLotService.kt | 2 +- .../stock/web/StockTakeRecordController.kt | 32 +- .../stock/web/model/StockTakeRecordReponse.kt | 22 + .../20260405_01_Enson/01_alter_stock_take.sql | 8 + 13 files changed, 896 insertions(+), 334 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260405_01_Enson/01_alter_stock_take.sql diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRepository.kt index 3f3019d..560dd0b 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRepository.kt @@ -54,4 +54,29 @@ fun findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( requiredDeliveryDate: LocalDate, ticketStatus: List ): List + + /** + * 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, + ): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index 12fd41d..11966e5 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -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 { diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt index d07fad0..d6f4191 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt @@ -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 }) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt index 87cb0c6..40c3c55 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt @@ -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() 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) { - 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) { + 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 { diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt index 2d673f6..8b5e8c6 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt @@ -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 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLine.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLine.kt index f60d4d5..a96341f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLine.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLine.kt @@ -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() { @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 } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt index 496875b..648ef52 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt @@ -8,6 +8,7 @@ import java.io.Serializable interface StockTakeLineRepository : AbstractRepository { fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?; fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?; + fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine? fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse( stockTakeIds: Collection, inventoryLotLineIds: Collection diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 9d492fe..3cd7d97 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -11,6 +11,7 @@ import com.ffii.fpsms.modules.stock.web.model.* import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime import java.math.BigDecimal import com.ffii.fpsms.modules.user.entity.UserRepository @@ -47,7 +48,7 @@ import java.math.RoundingMode import com.ffii.fpsms.modules.stock.entity.StockTake import com.ffii.fpsms.modules.master.entity.ItemsRepository @Service -class StockTakeRecordService( +open class StockTakeRecordService( val stockTakeRepository: StockTakeRepository, val warehouseRepository: WarehouseRepository, val inventoryLotLineRepository: InventoryLotLineRepository, @@ -68,6 +69,87 @@ class StockTakeRecordService( ) { private val logger: Logger = LoggerFactory.getLogger(StockTakeRecordService::class.java) + /** 建立盤點輪次時預建 stock_take_record 用的佔位盤點員(取任一有效 user id)。 */ + open fun resolvePlaceholderStockTakerId(): Long { + val u = userRepository.findAll(PageRequest.of(0, 1)).firstOrNull() + ?: throw IllegalStateException("No user in system; cannot create placeholder stock take records") + return u.id ?: throw IllegalStateException("User id is null") + } + + /** + * 建立該 section 的 stock_take 後,依當下「可用」庫存行預先建立本輪全部盤點明細(pending),盤點員儲存時改為更新。 + */ + @Transactional + open fun createPlaceholderStockTakeRecordsForStockTake(stockTake: StockTake, placeholderStockTakerId: Long) { + val section = stockTake.stockTakeSection ?: return + val warehouses = warehouseRepository.findAllByStockTakeSectionAndDeletedIsFalse(section) + val warehouseIds = warehouses.mapNotNull { it.id } + if (warehouseIds.isEmpty()) return + + val roundId = stockTake.stockTakeRoundId ?: stockTake.id + ?: throw IllegalStateException("stockTake id is null") + + val inventoryLotLines = + inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalseAndStatusIsAvailable(warehouseIds) + if (inventoryLotLines.isEmpty()) return + + val toSave = ArrayList(inventoryLotLines.size) + val illPerRecord = ArrayList(inventoryLotLines.size) + for (ill in inventoryLotLines) { + val inventoryLot = ill.inventoryLot ?: continue + val warehouse = ill.warehouse ?: continue + val item = inventoryLot.item ?: continue + val availableQty = (ill.inQty ?: BigDecimal.ZERO) + .subtract(ill.outQty ?: BigDecimal.ZERO) + //.subtract(ill.holdQty ?: BigDecimal.ZERO) + + // 與舊版 getInventoryLotDetails 顯示規則一致:僅 availableQty > 0 才預建(排除 in−out=0 或全被 hold 扣光等) + if (availableQty.compareTo(BigDecimal.ZERO) <= 0) { + continue + } + + toSave.add( + StockTakeRecord().apply { + this.itemId = item.id + this.lotId = inventoryLot.id + this.warehouse = warehouse + this.stockTake = stockTake + this.stockTakeRoundId = roundId + this.stockTakeSection = section + this.inventoryLotId = inventoryLot.id + this.stockTakerId = placeholderStockTakerId + this.stockTakerName = null + this.bookQty = availableQty + this.uom = ill.stockUom?.uom?.udfudesc + this.date = LocalDate.now() + this.status = "pending" + this.itemCode = item.code + this.itemName = item.name + this.lotNo = inventoryLot.lotNo + this.expiredDate = inventoryLot.expiryDate + } + ) + illPerRecord.add(ill) + } + stockTakeRecordRepository.saveAll(toSave) + + // 開輪即建 stock_take_line,1 record : 1 line,綁定 stockTakeRecordId(核准差異為 0 時改為完成同一筆,不另建) + if (toSave.isNotEmpty()) { + val lines = toSave.zip(illPerRecord).map { (record, ill) -> + StockTakeLine().apply { + this.stockTake = stockTake + this.inventoryLotLine = ill + this.initialQty = record.bookQty + this.finalQty = null + this.uom = ill.stockUom?.uom + this.status = StockTakeLineStatus.PENDING + this.stockTakeRecord = record + } + } + stockTakeLineRepository.saveAll(lines) + } + } + /** * 同一輪多 section 的 stock_take:優先用 [StockTake.stockTakeRoundId];舊資料為 null 時退回以 planStart 相同辨識一輪。 */ @@ -339,18 +421,46 @@ class StockTakeRecordService( val sectionDescription = warehouses .mapNotNull { it.stockTakeSectionDescription } // 先去掉 null .distinct() // 去重(防止误填多个不同值) - .firstOrNull() - // 9. 计算 TotalItemNumber:获取该 section 下所有 InventoryLotLine,按 item 分组,计算不同的 item 数量 - val totalItemNumber = inventoryLotLineRepository.countDistinctItemsByWarehouseIds(warehouseIds).toInt() - val totalInventoryLotNumber = inventoryLotLineRepository.countAllByWarehouseIds(warehouseIds).toInt() - // 8. 使用 stockTakeSection 作为 stockTakeSession + .firstOrNull() + + val roundIdForLatest = latestStockTake?.let { st -> st.stockTakeRoundId ?: st.id } + val roundRecordsForSection = if (latestStockTake != null && roundIdForLatest != null) { + allStockTakeRecords.filter { + it.stockTakeRoundId == roundIdForLatest && + it.stockTakeSection == stockTakeSection && + (it.warehouse?.id in warehouseIds) + } + } else { + emptyList() + } + + val totalItemNumber = if (roundRecordsForSection.isNotEmpty()) { + roundRecordsForSection.mapNotNull { it.itemId }.distinct().count() + } else { + inventoryLotLineRepository.countDistinctItemsByWarehouseIds(warehouseIds).toInt() + } + val totalInventoryLotNumber = if (roundRecordsForSection.isNotEmpty()) { + roundRecordsForSection.size + } else { + inventoryLotLineRepository.countAllByWarehouseIds(warehouseIds).toInt() + } + val currentStockTakeItemNumber = roundRecordsForSection.count { it.pickerFirstStockTakeQty != null } + val reStockTakeTrueFalse = if (latestStockTake != null) { - // 检查该 stock take 下该 section 的记录中是否有 notMatch 状态 - val hasNotMatch = allStockTakeRecords.any { - !it.deleted && + val hasNotMatch = if (roundIdForLatest != null) { + allStockTakeRecords.any { + !it.deleted && + it.stockTakeRoundId == roundIdForLatest && + it.stockTakeSection == stockTakeSection && + it.status == "notMatch" + } + } else { + allStockTakeRecords.any { + !it.deleted && it.stockTake?.id == latestStockTake.id && it.stockTakeSection == stockTakeSection && it.status == "notMatch" + } } hasNotMatch } else { @@ -362,7 +472,7 @@ class StockTakeRecordService( stockTakeSession = stockTakeSection, lastStockTakeDate = latestStockTake?.actualStart?.toLocalDate(), status = status ?: "", - currentStockTakeItemNumber = 0, + currentStockTakeItemNumber = currentStockTakeItemNumber, totalInventoryLotNumber = totalInventoryLotNumber, stockTakeId = latestStockTake?.id ?: 0, stockTakeRoundId = latestStockTake?.stockTakeRoundId ?: latestStockTake?.id, @@ -807,10 +917,14 @@ class StockTakeRecordService( open fun getInventoryLotDetailsByStockTakeSection( stockTakeSection: String, stockTakeId: Long? = null, + stockTakeRoundId: Long? = null, pageNum: Int = 0, pageSize: Int = 10 ): RecordsRes { - println("getInventoryLotDetailsByStockTakeSection called with section: $stockTakeSection, stockTakeId: $stockTakeId, pageNum: $pageNum, pageSize: $pageSize") + println( + "getInventoryLotDetailsByStockTakeSection called with section: $stockTakeSection, stockTakeId: $stockTakeId, " + + "stockTakeRoundId: $stockTakeRoundId, pageNum: $pageNum, pageSize: $pageSize" + ) val warehouses = warehouseRepository.findAllByStockTakeSectionAndDeletedIsFalse(stockTakeSection) if (warehouses.isEmpty()) { @@ -818,20 +932,123 @@ class StockTakeRecordService( return RecordsRes(emptyList(), 0) } - val warehouseIds = warehouses.mapNotNull { it.id } + val warehouseIds = warehouses.mapNotNull { it.id }.toSet() println("Found ${warehouses.size} warehouses for section $stockTakeSection") - val inventoryLotLines = inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalseAndStatusIsAvailable(warehouseIds) - println("Found ${inventoryLotLines.size} inventory lot lines") + if (stockTakeId != null) { + val baseStockTake = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId) + ?: return RecordsRes(emptyList(), 0) + val baseId = baseStockTake.id ?: return RecordsRes(emptyList(), 0) + val roundId = stockTakeRoundId ?: baseStockTake.stockTakeRoundId ?: baseId + val sectionRecords = stockTakeRecordRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(roundId) + .filter { + !it.deleted && + it.stockTakeSection == stockTakeSection && + (it.warehouse?.id in warehouseIds) + } + + if (sectionRecords.isNotEmpty()) { + val lotIds = sectionRecords.mapNotNull { it.lotId }.toSet() + val whIds = sectionRecords.mapNotNull { it.warehouse?.id }.toSet() + val illList = + if (lotIds.isNotEmpty() && whIds.isNotEmpty()) { + inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse( + whIds, + lotIds + ) + } else { + emptyList() + } + val illByPair = illList.associateBy { + Pair(it.inventoryLot?.id ?: 0L, it.warehouse?.id ?: 0L) + } + + val effectiveStockTakeId = stockTakeId + val allResults = sectionRecords.mapNotNull { rec -> + val ill = illByPair[Pair(rec.lotId ?: 0L, rec.warehouse?.id ?: 0L)] + ?: return@mapNotNull null + val inventoryLot = ill.inventoryLot + val item = inventoryLot?.item + val warehouse = ill.warehouse + val availableQty = (ill.inQty ?: BigDecimal.ZERO) + .subtract(ill.outQty ?: BigDecimal.ZERO) + .subtract(ill.holdQty ?: BigDecimal.ZERO) + val inventoryLotLineId = ill.id + val stockTakeLine = + if (effectiveStockTakeId != null && inventoryLotLineId != null) { + stockTakeLineRepository.findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse( + inventoryLotLineId, + effectiveStockTakeId + ) + } else { + null + } + InventoryLotDetailResponse( + id = ill.id ?: 0L, + inventoryLotId = inventoryLot?.id ?: 0L, + itemId = item?.id ?: 0L, + itemCode = item?.code, + itemName = item?.name, + lotNo = inventoryLot?.lotNo, + expiryDate = inventoryLot?.expiryDate, + storeId = warehouse?.store_id, + productionDate = inventoryLot?.productionDate, + stockInDate = inventoryLot?.stockInDate, + inQty = ill.inQty, + remarks = rec.remarks, + outQty = ill.outQty, + holdQty = ill.holdQty, + availableQty = availableQty, + uom = ill.stockUom?.uom?.udfudesc, + warehouseCode = warehouse?.code, + warehouseArea = warehouse?.area, + warehouseName = warehouse?.name, + status = ill.status?.name, + warehouseSlot = warehouse?.slot, + warehouse = warehouse?.warehouse, + varianceQty = rec.varianceQty, + stockTakeRecordId = rec.id, + stockTakeRecordStatus = rec.status, + firstStockTakeQty = rec.pickerFirstStockTakeQty, + secondStockTakeQty = rec.pickerSecondStockTakeQty, + firstBadQty = rec.pickerFirstBadQty, + secondBadQty = rec.pickerSecondBadQty, + approverQty = rec.approverStockTakeQty, + approverBadQty = rec.approverBadQty, + finalQty = stockTakeLine?.finalQty, + bookQty = rec.bookQty, + stockTakeSection = rec.stockTakeSection ?: warehouse?.stockTakeSection, + stockTakeSectionDescription = warehouse?.stockTakeSectionDescription, + stockTakerName = rec.stockTakerName, + stockTakeEndTime = rec.stockTakeEndTime, + approverTime = rec.approverTime, + ) + } + + val pageable = PageRequest.of(pageNum, pageSize) + val startIndex = pageable.offset.toInt() + val filteredResults = allResults.sortedWith(compareBy { it.itemCode }) + val endIndex = minOf(startIndex + pageSize, filteredResults.size) + val paginatedResult = if (startIndex < filteredResults.size) { + filteredResults.subList(startIndex, endIndex) + } else { + emptyList() + } + return RecordsRes(paginatedResult, filteredResults.size) + } + } + + val inventoryLotLines = + inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalseAndStatusIsAvailable(warehouseIds.toList()) + println("Found ${inventoryLotLines.size} inventory lot lines (legacy inventory-driven)") val stockTakeRecordsMap = if (stockTakeId != null) { val allStockTakeRecords = stockTakeRecordRepository.findAll() .filter { !it.deleted && - it.stockTake?.id == stockTakeId && - it.warehouse?.id in warehouseIds + it.stockTake?.id == stockTakeId && + it.warehouse?.id in warehouseIds } - // 按 lotId 和 warehouseId 建立映射 allStockTakeRecords.associateBy { Pair(it.lotId ?: 0L, it.warehouse?.id ?: 0L) } @@ -853,10 +1070,14 @@ class StockTakeRecordService( null } val inventoryLotLineId = ill.id - val stockTakeLine = stockTakeLineRepository.findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse( - inventoryLotLineId, - stockTakeId!! - ) + val stockTakeLine = if (stockTakeId != null && inventoryLotLineId != null) { + stockTakeLineRepository.findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse( + inventoryLotLineId, + stockTakeId + ) + } else { + null + } InventoryLotDetailResponse( id = ill.id ?: 0L, inventoryLotId = inventoryLot?.id ?: 0L, @@ -879,7 +1100,6 @@ class StockTakeRecordService( warehouseName = warehouse?.name, status = ill.status?.name, warehouseSlot = warehouse?.slot, - warehouse = warehouse?.warehouse, varianceQty = stockTakeRecord?.varianceQty, stockTakeRecordId = stockTakeRecord?.id, @@ -900,31 +1120,29 @@ class StockTakeRecordService( ) } - // Apply pagination val pageable = PageRequest.of(pageNum, pageSize) -val startIndex = pageable.offset.toInt() -val filteredResults = allResults.filter { response -> - val av = response.availableQty ?: BigDecimal.ZERO - // 显示: availableQty > 0,或已有盘点记录(即使盘点结果为 0) - av.compareTo(BigDecimal.ZERO) > 0 || response.stockTakeRecordId != null -}.sortedWith( - compareBy { it.itemCode } -) -// endIndex 必須基於 filteredResults.size,不能用 allResults.size -val endIndex = minOf(startIndex + pageSize, filteredResults.size) -val paginatedResult = if (startIndex < filteredResults.size) { - filteredResults.subList(startIndex, endIndex) -} else { - emptyList() -} -return RecordsRes(paginatedResult, filteredResults.size) + val startIndex = pageable.offset.toInt() + val filteredResults = allResults.filter { response -> + val av = response.availableQty ?: BigDecimal.ZERO + av.compareTo(BigDecimal.ZERO) > 0 || response.stockTakeRecordId != null + }.sortedWith( + compareBy { it.itemCode } + ) + val endIndex = minOf(startIndex + pageSize, filteredResults.size) + val paginatedResult = if (startIndex < filteredResults.size) { + filteredResults.subList(startIndex, endIndex) + } else { + emptyList() + } + return RecordsRes(paginatedResult, filteredResults.size) } + @Transactional open fun saveStockTakeRecord( request: SaveStockTakeRecordRequest, stockTakeId: Long, stockTakerId: Long - ): StockTakeRecord { + ): SaveStockTakeRecordResponse { println("saveStockTakeRecord called with stockTakeRecordId: ${request.stockTakeRecordId}, inventoryLotLineId: ${request.inventoryLotLineId}") val user = userRepository.findById(stockTakerId).orElse(null) // 1. 获取 inventory lot line @@ -945,34 +1163,48 @@ return RecordsRes(paginatedResult, filteredResults.size) .subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO) .subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) - // 3. 判断是创建还是更新 - val stockTakeRecord = if (request.stockTakeRecordId != null) { - // 更新现有记录(第二次盘点) + // 3. 新建、更新預建記錄之第一次盤點、或第二次盤點 + val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) { val existingRecord = stockTakeRecordRepository.findByIdAndDeletedIsFalse(request.stockTakeRecordId) ?: throw IllegalArgumentException("Stock take record not found: ${request.stockTakeRecordId}") - // 第二次盘点:允许不匹配,但根据匹配情况设置状态 - val totalInputQty = request.qty.add(request.badQty) - val isMatched = totalInputQty.compareTo(availableQty) == 0 - val varianceQty = availableQty - request.qty - request.badQty - // 更新字段(第二次盘点) - existingRecord.apply { - // 兼容旧数据:如果之前没写 round id,则补写 - this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id) - this.pickerSecondStockTakeQty = request.qty - this.pickerSecondBadQty = request.badQty // 更新 badQty - this.status = "pass" - this.remarks = request.remark ?: this.remarks - this.stockTakerName = user?.name - this.stockTakeEndTime = java.time.LocalDateTime.now() - - this.varianceQty = varianceQty + when { + existingRecord.pickerFirstStockTakeQty == null -> { + val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty + val varianceQty = availableQty - request.qty - request.badQty + existingRecord.apply { + this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id) + this.pickerFirstStockTakeQty = request.qty + this.pickerFirstBadQty = request.badQty + this.bookQty = availableQty + this.varianceQty = varianceQty + this.status = if (isCompled) "completed" else "pass" + this.remarks = request.remark ?: this.remarks + this.stockTakerId = stockTakerId + this.stockTakerName = user?.name + if (this.stockTakeStartTime == null) { + this.stockTakeStartTime = java.time.LocalDateTime.now() + } + } + existingRecord + } + existingRecord.pickerSecondStockTakeQty == null -> { + val varianceQty = availableQty - request.qty - request.badQty + existingRecord.apply { + this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id) + this.pickerSecondStockTakeQty = request.qty + this.pickerSecondBadQty = request.badQty + this.status = "pass" + this.remarks = request.remark ?: this.remarks + this.stockTakerName = user?.name + this.stockTakeEndTime = java.time.LocalDateTime.now() + this.varianceQty = varianceQty + } + existingRecord + } + else -> throw IllegalArgumentException("Stock take record already has first and second counts") } - existingRecord } else { - - val totalInputQty = request.qty.add(request.badQty) - val isMatched = totalInputQty.compareTo(availableQty) == 0 val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty val varianceQty = availableQty - request.qty - request.badQty StockTakeRecord().apply { @@ -1001,23 +1233,15 @@ return RecordsRes(paginatedResult, filteredResults.size) } } - val savedRecord = stockTakeRecordRepository.save(stockTakeRecord) - if (request.stockTakeRecordId == null) { - val existingRecordsCount = stockTakeRecordRepository.findAll() - .filter { - !it.deleted && - it.stockTake?.id == stockTakeId && - it.id != savedRecord.id // 排除刚创建的这条记录 - } - .count() - - - if (existingRecordsCount == 0 && stockTake.actualStart == null) { + if (stockTake.actualStart == null) { + val anyFirst = stockTakeRecordRepository.findAllByStockTakeIdAndDeletedIsFalse(stockTakeId) + .any { it.pickerFirstStockTakeQty != null } + if (anyFirst) { stockTake.actualStart = java.time.LocalDateTime.now() stockTake.status = StockTakeStatus.STOCKTAKING stockTakeRepository.save(stockTake) - println("Stock take $stockTakeId actualStart updated - first record created") + println("Stock take $stockTakeId actualStart updated - first picker count exists") } } @@ -1026,7 +1250,27 @@ return RecordsRes(paginatedResult, filteredResults.size) checkAndUpdateStockTakeStatus(stockTakeId, stockTakeSection) } - return savedRecord + return toSaveStockTakeRecordResponse(savedRecord) + } + + private fun toSaveStockTakeRecordResponse(r: StockTakeRecord): SaveStockTakeRecordResponse { + return SaveStockTakeRecordResponse( + id = r.id, + version = r.version, + itemId = r.itemId, + lotId = r.lotId, + inventoryLotId = r.inventoryLotId, + warehouseId = r.warehouse?.id, + stockTakeId = r.stockTake?.id, + stockTakeRoundId = r.stockTakeRoundId, + status = r.status, + bookQty = r.bookQty, + varianceQty = r.varianceQty, + pickerFirstStockTakeQty = r.pickerFirstStockTakeQty, + pickerFirstBadQty = r.pickerFirstBadQty, + pickerSecondStockTakeQty = r.pickerSecondStockTakeQty, + pickerSecondBadQty = r.pickerSecondBadQty, + ) } @@ -1181,58 +1425,36 @@ return RecordsRes(paginatedResult, filteredResults.size) return mapOf("success" to false, "message" to "No warehouses found for section") } - val warehouseIds = warehouses.mapNotNull { it.id } + val warehouseIds = warehouses.mapNotNull { it.id }.toSet() - // 2. 获取该 section 下的所有 inventory lot lines - val inventoryLotLines = inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalseAndStatusIsAvailable(warehouseIds) + val roundId = stockTake.stockTakeRoundId ?: stockTake.id + ?: return mapOf("success" to false, "message" to "Stock take id is null") - // 3. 获取该 stock take 下该 section 的所有记录 - val stockTakeRecords = stockTakeRecordRepository.findAll() + var stockTakeRecords = stockTakeRecordRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(roundId) .filter { !it.deleted && + it.stockTakeSection == stockTakeSection && + (it.warehouse?.id in warehouseIds) + } + + if (stockTakeRecords.isEmpty()) { + stockTakeRecords = stockTakeRecordRepository.findAll() + .filter { + !it.deleted && it.stockTake?.id == stockTakeId && it.warehouse?.id in warehouseIds && it.stockTakeSection == stockTakeSection - } - - // 4. 仅对「前端会显示的库存行」做检查: - // 规则与 getInventoryLotDetailsByStockTakeSection 一致: - // - availableQty > 0,或 - // - 已经有 stockTakeRecord(即使 availableQty 为 0) - val relevantInventoryLotLines = inventoryLotLines.filter { ill -> - val inventoryLot = ill.inventoryLot - val warehouse = ill.warehouse - if (inventoryLot?.id == null || warehouse?.id == null) { - false - } else { - val inQty = ill.inQty ?: BigDecimal.ZERO - val outQty = ill.outQty ?: BigDecimal.ZERO - val holdQty = ill.holdQty ?: BigDecimal.ZERO - val availableQty = inQty.subtract(outQty).subtract(holdQty) - - val hasRecord = stockTakeRecords.any { record -> - record.inventoryLotId == inventoryLot.id && - record.warehouse?.id == warehouse.id } - availableQty.compareTo(BigDecimal.ZERO) > 0 || hasRecord - } - } - - // 5. 检查这些「相关行」是否都有对应的记录 - val allLinesHaveRecords = relevantInventoryLotLines.all { ill -> - val inventoryLot = ill.inventoryLot - val warehouse = ill.warehouse - stockTakeRecords.any { record -> - record.inventoryLotId == inventoryLot?.id && - record.warehouse?.id == warehouse?.id - } } + // 輪次預建後:以本 section 本輪「全部記錄是否已完成第一次盤點」為準(不再與即時庫存行逐行對齊) + val allLinesHaveRecords = stockTakeRecords.isNotEmpty() && + stockTakeRecords.all { it.pickerFirstStockTakeQty != null } val allRecordsPassed = stockTakeRecords.isNotEmpty() && - stockTakeRecords.all { it.status == "pass" || it.status == "completed" } + stockTakeRecords.all { it.status == "pass" || it.status == "completed" } val allRecordsCompleted = stockTakeRecords.isNotEmpty() && - stockTakeRecords.all { it.status == "completed" } + stockTakeRecords.all { it.status == "completed" } // 6. 如果所有记录都已创建且都是 "pass",更新 stock take 状态为 "approving" if (allLinesHaveRecords && allRecordsCompleted) { stockTake.status = StockTakeStatus.COMPLETED @@ -1338,21 +1560,12 @@ return RecordsRes(paginatedResult, filteredResults.size) val savedRecord = stockTakeRecordRepository.save(stockTakeRecord) - // stockTakeRecord.inventoryLotId 是 inventory_lot 主鍵,不是 inventory_lot_line 主鍵;必須用倉庫+批次定位庫存行 - val inventoryLotLine = resolveInventoryLotLineForStockTakeRecord(savedRecord) - - val inventoryLot = inventoryLotRepository.findByIdAndDeletedFalse( - inventoryLotLine.inventoryLot?.id ?: throw IllegalArgumentException("Inventory lot ID not found") - ) ?: throw IllegalArgumentException("Inventory lot not found") - - val inventory = inventoryRepository.findByItemId( - inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found") - ).orElse(null) ?: throw IllegalArgumentException("Inventory not found for item") - - // 只有 variance != 0 时才做调整 - if (varianceQty != BigDecimal.ZERO) { - applyVarianceAdjustment(stockTake, stockTakeRecord, finalQty!!, varianceQty, request.approverId) - } + // variance != 0:過帳並更新/新建 StockTakeLine;= 0:僅將開輪預建 line 標記完成(不查庫存) + if (varianceQty != BigDecimal.ZERO) { + applyVarianceAdjustment(stockTake, stockTakeRecord, finalQty!!, varianceQty, request.approverId) + } else { + completeStockTakeLineForApproverNoVariance(stockTake, savedRecord, finalQty!!) + } val stockTakeSection = savedRecord.stockTakeSection if (stockTakeSection != null) { checkAndUpdateStockTakeStatus(stockTakeId, stockTakeSection) @@ -1462,6 +1675,8 @@ open fun batchSaveApproverStockTakeRecords( errors.add("Record ${record.id}: ${e.message}") return@forEach } + } else { + completeStockTakeLineForApproverNoVariance(stockTake, record, qty) } successCount++ } catch (e: Exception) { @@ -1609,6 +1824,8 @@ if (itemParts.isNotEmpty()) { 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 @@ -1659,8 +1876,32 @@ private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord): } /** - * 根据 variance 调整库存并创建 Stock Ledger。 - * 当 variance != 0 时:创建 StockTakeLine,并根据 variance 正负创建 StockIn/StockOut 及 Ledger。 + * 核准後差異為 0:將開輪預建的 [StockTakeLine] 標記為完成(不過帳)。 + * 舊資料無預建 line 時不做事。 + */ +private fun completeStockTakeLineForApproverNoVariance( + stockTake: StockTake, + stockTakeRecord: StockTakeRecord, + finalQty: BigDecimal +) { + val rid = stockTakeRecord.id ?: return + val line = stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) ?: return + line.apply { + this.stockTake = stockTake + this.initialQty = this.initialQty ?: stockTakeRecord.bookQty + this.finalQty = finalQty + this.status = StockTakeLineStatus.COMPLETED + this.completeDate = LocalDateTime.now() + this.stockTakeRecord = stockTakeRecord + } + stockTakeLineRepository.save(line) +} + +/** + * 依盤點結果調整庫存並寫 Ledger。 + * - 盤虧:實際出庫量 = **核准當下**該庫存行剩餘(in−out−hold)與盤點目標 [finalQty] 之差,對齊「期中已有出庫」的情境(例如 book 快照 1000、期中出 250、盤 0 → 只再沖 750)。 + * - 盤盈:將盤盈量 **加在既有** [InventoryLotLine.inQty](同一批/倉),不新建 [InventoryLot];TKE 的 [StockInLine] 不綁 inventoryLotLine,避免與原入庫單行 OneToOne 衝突。 + * - [stockTakeRecord.varianceQty] 仍為 picker/approver 數量 − 預建 bookQty(報表用),與此處實際過帳量可不同。 */ private fun applyVarianceAdjustment( stockTake: StockTake, @@ -1681,20 +1922,42 @@ private fun applyVarianceAdjustment( inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found") ).orElse(null) ?: throw IllegalArgumentException("Inventory not found for item") - // 1. 创建 StockTakeLine - val stockTakeLine = StockTakeLine().apply { + // 1. 更新開輪預建的 StockTakeLine,或舊資料無預建時新建 + val stockTakeLine = stockTakeRecord.id?.let { rid -> + stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) + }?.also { existing -> + existing.apply { + this.stockTake = stockTake + this.inventoryLotLine = inventoryLotLine + this.initialQty = this.initialQty ?: stockTakeRecord.bookQty + this.finalQty = finalQty + this.status = StockTakeLineStatus.COMPLETED + this.completeDate = LocalDateTime.now() + this.stockTakeRecord = stockTakeRecord + } + } ?: StockTakeLine().apply { this.stockTake = stockTake this.inventoryLotLine = inventoryLotLine this.initialQty = stockTakeRecord.bookQty this.finalQty = finalQty this.status = StockTakeLineStatus.COMPLETED - this.completeDate = java.time.LocalDateTime.now() + this.completeDate = LocalDateTime.now() + this.stockTakeRecord = stockTakeRecord } stockTakeLineRepository.save(stockTakeLine) - // 2. variance < 0:出库 + StockLedger + val zero = BigDecimal.ZERO + + // 2. variance < 0:出库 + StockLedger(實際沖銷量依「當下庫存行」計算) if (varianceQty < BigDecimal.ZERO) { - val absVariance = varianceQty.negate() + val latestLine = inventoryLotLineRepository.findById(inventoryLotLine.id!!).orElseThrow() + val currentRemaining = (latestLine.inQty ?: zero) + .subtract(latestLine.outQty ?: zero) + .subtract(latestLine.holdQty ?: zero) + val qtyToRemove = currentRemaining.subtract(finalQty).coerceAtLeast(zero) + if (qtyToRemove.compareTo(zero) == 0) { + return + } var stockOut = stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTake.id!!) ?: StockOut().apply { @@ -1705,9 +1968,9 @@ private fun applyVarianceAdjustment( val stockOutLine = StockOutLine().apply { this.item = inventoryLot.item - this.qty = absVariance.toDouble() + this.qty = qtyToRemove.toDouble() this.stockOut = stockOut - this.inventoryLotLine = inventoryLotLine + this.inventoryLotLine = latestLine this.status = "completed" this.type = "TKE" } @@ -1720,39 +1983,41 @@ private fun applyVarianceAdjustment( val latestLedger = stockLedgerRepository.findLatestByItemId(itemIdForLedger).firstOrNull() val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() - val newBalance = previousBalance - absVariance.toDouble() + val newBalance = previousBalance - qtyToRemove.toDouble() val stockLedger = StockLedger().apply { this.inventory = inventory this.itemId = inventoryLot.item?.id this.itemCode = stockTakeRecord.itemCode this.inQty = null - this.outQty = absVariance.toDouble() + this.outQty = qtyToRemove.toDouble() this.stockOutLine = stockOutLine this.balance = newBalance this.type = "TKE" - this.uomId = inventoryLotLine.stockUom?.uom?.id ?: inventory.uom?.id + this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id this.date = LocalDate.now() } stockLedgerRepository.saveAndFlush(stockLedger) - val newOutQty = (inventoryLotLine.outQty ?: BigDecimal.ZERO).add(absVariance) + val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove) val updateRequest = SaveInventoryLotLineRequest( - id = inventoryLotLine.id, - inventoryLotId = inventoryLotLine.inventoryLot?.id, - warehouseId = inventoryLotLine.warehouse?.id, - stockUomId = inventoryLotLine.stockUom?.id, - inQty = inventoryLotLine.inQty, + id = latestLine.id, + inventoryLotId = latestLine.inventoryLot?.id, + warehouseId = latestLine.warehouse?.id, + stockUomId = latestLine.stockUom?.id, + inQty = latestLine.inQty, outQty = newOutQty, - holdQty = inventoryLotLine.holdQty, - status = inventoryLotLine.status?.value, - remarks = inventoryLotLine.remarks + holdQty = latestLine.holdQty, + status = latestLine.status?.value, + remarks = latestLine.remarks ) inventoryLotLineService.saveInventoryLotLine(updateRequest) } - // 3. variance > 0:入库 + StockLedger + // 3. variance > 0:入庫加在既有庫存行 inQty + StockLedger if (varianceQty > BigDecimal.ZERO) { val plusQty = varianceQty + val latestLine = inventoryLotLineRepository.findById(inventoryLotLine.id!!).orElseThrow() + val newInQty = (latestLine.inQty ?: zero).add(plusQty) var stockIn = stockInRepository.findByStockTakeIdAndDeletedFalse(stockTake.id!!) ?: StockIn().apply { @@ -1772,36 +2037,26 @@ private fun applyVarianceAdjustment( this.lotNo = inventoryLot.lotNo this.status = "completed" this.type = "TKE" + this.inventoryLot = inventoryLot + // 原入庫已綁定之 inventory_lot_line 與 SIL 多為 OneToOne;盤盈改加同一行 inQty,此處不綁 line 以免衝突 + this.inventoryLotLine = null } stockInLineRepository.save(stockInLine) - val newInventoryLot = InventoryLot().apply { - this.item = inventoryLot.item - this.lotNo = inventoryLot.lotNo - this.expiryDate = inventoryLot.expiryDate - this.productionDate = inventoryLot.productionDate - this.stockInDate = LocalDateTime.now() - this.stockInLine = stockInLine - } - inventoryLotRepository.save(newInventoryLot) - - val newInventoryLotLine = InventoryLotLine().apply { - this.inventoryLot = newInventoryLot - this.warehouse = inventoryLotLine.warehouse - this.stockUom = inventoryLotLine.stockUom - this.inQty = plusQty - this.outQty = BigDecimal.ZERO - this.holdQty = BigDecimal.ZERO - this.status = inventoryLotLine.status - this.remarks = "Stock take adjustment (+$plusQty)" - } - inventoryLotLineRepository.save(newInventoryLotLine) - - stockInLine.inventoryLot = newInventoryLot - stockInLine.inventoryLotLine = newInventoryLotLine - stockInLineRepository.save(stockInLine) + val updateRequest = SaveInventoryLotLineRequest( + id = latestLine.id, + inventoryLotId = latestLine.inventoryLot?.id, + warehouseId = latestLine.warehouse?.id, + stockUomId = latestLine.stockUom?.id, + inQty = newInQty, + outQty = latestLine.outQty, + holdQty = latestLine.holdQty, + status = latestLine.status?.value, + remarks = latestLine.remarks + ) + inventoryLotLineService.saveInventoryLotLine(updateRequest) - val itemIdForLedger = newInventoryLot.item?.id + val itemIdForLedger = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") val latestLedger = stockLedgerRepository.findLatestByItemId(itemIdForLedger).firstOrNull() val previousBalance = latestLedger?.balance @@ -1809,14 +2064,14 @@ private fun applyVarianceAdjustment( val newBalance = previousBalance + plusQty.toDouble() val stockLedger = StockLedger().apply { this.inventory = inventory - this.itemId = newInventoryLot.item?.id - this.itemCode = newInventoryLot.item?.code + this.itemId = inventoryLot.item?.id + this.itemCode = inventoryLot.item?.code this.inQty = plusQty.toDouble() this.outQty = null this.stockInLine = stockInLine this.balance = newBalance this.type = "TKE" - this.uomId = inventoryLotLine.stockUom?.uom?.id ?: inventory.uom?.id + this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id this.date = LocalDate.now() } stockLedgerRepository.saveAndFlush(stockLedger) @@ -1836,74 +2091,178 @@ open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTa } open fun getInventoryLotDetailsByStockTakeSectionNotMatch( - stockTakeSection: String, + stockTakeSection: String, stockTakeId: Long? = null, + stockTakeRoundId: Long? = null, pageNum: Int = 0, - pageSize: Int = Int.MAX_VALUE // Default to return all if not specified + pageSize: Int = Int.MAX_VALUE ): RecordsRes { - println("getInventoryLotDetailsByStockTakeSectionNotMatch called with section: $stockTakeSection, stockTakeId: $stockTakeId, pageNum: $pageNum, pageSize: $pageSize") - + println( + "getInventoryLotDetailsByStockTakeSectionNotMatch called with section: $stockTakeSection, stockTakeId: $stockTakeId, " + + "stockTakeRoundId: $stockTakeRoundId, pageNum: $pageNum, pageSize: $pageSize" + ) + val warehouses = warehouseRepository.findAllByStockTakeSectionAndDeletedIsFalse(stockTakeSection) if (warehouses.isEmpty()) { logger.warn("No warehouses found for stockTakeSection: $stockTakeSection") return RecordsRes(emptyList(), 0) } - - val warehouseIds = warehouses.mapNotNull { it.id } + + val warehouseIds = warehouses.mapNotNull { it.id }.toSet() println("Found ${warehouses.size} warehouses for section $stockTakeSection") - - val inventoryLotLines = inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalse(warehouseIds) - println("Found ${inventoryLotLines.size} inventory lot lines") - - val stockTakeRecordsMap = if (stockTakeId != null) { - val allStockTakeRecords = stockTakeRecordRepository.findAll() - .filter { - !it.deleted && - it.stockTake?.id == stockTakeId && - it.warehouse?.id in warehouseIds && - it.status == "notMatch" // 只获取 notMatch 状态的记录 + + if (stockTakeId != null) { + val baseStockTake = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId) + ?: return RecordsRes(emptyList(), 0) + val baseId = baseStockTake.id ?: return RecordsRes(emptyList(), 0) + val roundId = stockTakeRoundId ?: baseStockTake.stockTakeRoundId ?: baseId + + val notMatchRecords = stockTakeRecordRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(roundId) + .filter { + !it.deleted && + it.stockTakeSection == stockTakeSection && + (it.warehouse?.id in warehouseIds) && + it.status == "notMatch" } - // 按 lotId 和 warehouseId 建立映射 - allStockTakeRecords.associateBy { - Pair(it.lotId ?: 0L, it.warehouse?.id ?: 0L) + + if (notMatchRecords.isNotEmpty()) { + val lotIds = notMatchRecords.mapNotNull { it.lotId }.toSet() + val whIds = notMatchRecords.mapNotNull { it.warehouse?.id }.toSet() + val illList = + if (lotIds.isNotEmpty() && whIds.isNotEmpty()) { + inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse(whIds, lotIds) + } else { + emptyList() + } + val illByPair = illList.associateBy { + Pair(it.inventoryLot?.id ?: 0L, it.warehouse?.id ?: 0L) + } + + val effectiveStockTakeId = stockTakeId + val allResults = notMatchRecords.mapNotNull { rec -> + val ill = illByPair[Pair(rec.lotId ?: 0L, rec.warehouse?.id ?: 0L)] + ?: return@mapNotNull null + val inventoryLot = ill.inventoryLot + val warehouse = ill.warehouse + val availableQty = (ill.inQty ?: BigDecimal.ZERO) + .subtract(ill.outQty ?: BigDecimal.ZERO) + .subtract(ill.holdQty ?: BigDecimal.ZERO) + val inventoryLotLineId = ill.id + val stockTakeLine = + if (effectiveStockTakeId != null && inventoryLotLineId != null) { + stockTakeLineRepository.findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse( + inventoryLotLineId, + effectiveStockTakeId + ) + } else { + null + } + InventoryLotDetailResponse( + id = ill.id ?: 0L, + inventoryLotId = inventoryLot?.id ?: 0L, + itemId = inventoryLot?.item?.id ?: 0L, + itemCode = inventoryLot?.item?.code, + itemName = inventoryLot?.item?.name, + lotNo = inventoryLot?.lotNo, + expiryDate = inventoryLot?.expiryDate, + productionDate = inventoryLot?.productionDate, + stockInDate = inventoryLot?.stockInDate, + inQty = ill.inQty, + remarks = rec.remarks, + outQty = ill.outQty, + holdQty = ill.holdQty, + availableQty = availableQty, + uom = ill.stockUom?.uom?.udfudesc, + warehouseCode = warehouse?.code, + warehouseName = warehouse?.name, + status = ill.status?.name, + warehouseSlot = warehouse?.slot, + warehouseArea = warehouse?.area, + warehouse = warehouse?.warehouse, + storeId = warehouse?.store_id, + varianceQty = rec.varianceQty, + stockTakeRecordId = rec.id, + stockTakeRecordStatus = rec.status, + firstStockTakeQty = rec.pickerFirstStockTakeQty, + secondStockTakeQty = rec.pickerSecondStockTakeQty, + firstBadQty = rec.pickerFirstBadQty, + secondBadQty = rec.pickerSecondBadQty, + approverQty = rec.approverStockTakeQty, + approverBadQty = rec.approverBadQty, + finalQty = stockTakeLine?.finalQty, + bookQty = rec.bookQty, + stockTakeSection = rec.stockTakeSection ?: warehouse?.stockTakeSection, + stockTakeSectionDescription = warehouse?.stockTakeSectionDescription, + stockTakerName = rec.stockTakerName, + stockTakeEndTime = rec.stockTakeEndTime, + approverTime = rec.approverTime, + ) + }.sortedWith(compareBy { it.itemCode }) + + val paginatedResult = if (pageSize >= allResults.size) { + allResults + } else { + val pageable = PageRequest.of(pageNum, pageSize) + val startIndex = pageable.offset.toInt() + val endIndex = minOf(startIndex + pageSize, allResults.size) + if (startIndex < allResults.size) { + allResults.subList(startIndex, endIndex) + } else { + emptyList() + } + } + return RecordsRes(paginatedResult, allResults.size) } + } + + val inventoryLotLines = inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalse(warehouseIds.toList()) + println("Found ${inventoryLotLines.size} inventory lot lines (legacy)") + + val stockTakeRecordsMap = if (stockTakeId != null) { + stockTakeRecordRepository.findAll() + .filter { + !it.deleted && + it.stockTake?.id == stockTakeId && + it.warehouse?.id in warehouseIds && + it.status == "notMatch" + } + .associateBy { + Pair(it.lotId ?: 0L, it.warehouse?.id ?: 0L) + } } else { emptyMap() } - - // 只返回有 notMatch 记录的 inventory lot lines + val allResults = inventoryLotLines.mapNotNull { ill -> val inventoryLot = ill.inventoryLot - val item = inventoryLot?.item val warehouse = ill.warehouse - val availableQty = (ill.inQty ?: BigDecimal.ZERO) + val availableQty = (ill.inQty ?: BigDecimal.ZERO) .subtract(ill.outQty ?: BigDecimal.ZERO) .subtract(ill.holdQty ?: BigDecimal.ZERO) - + val stockTakeRecord = if (stockTakeId != null && inventoryLot?.id != null && warehouse?.id != null) { stockTakeRecordsMap[Pair(inventoryLot.id, warehouse.id)] } else { null } - - // 只返回状态为 notMatch 的记录 + if (stockTakeRecord == null || stockTakeRecord.status != "notMatch") { return@mapNotNull null } - + val inventoryLotLineId = ill.id - val stockTakeLine = if (stockTakeId != null) { + val stockTakeLine = if (stockTakeId != null && inventoryLotLineId != null) { stockTakeLineRepository.findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId, stockTakeId) } else { null } - + InventoryLotDetailResponse( id = ill.id ?: 0L, inventoryLotId = inventoryLot?.id ?: 0L, - itemId = item?.id ?: 0L, - itemCode = item?.code, - itemName = item?.name, + itemId = inventoryLot?.item?.id ?: 0L, + itemCode = inventoryLot?.item?.code, + itemName = inventoryLot?.item?.name, lotNo = inventoryLot?.lotNo, expiryDate = inventoryLot?.expiryDate, productionDate = inventoryLot?.productionDate, @@ -1939,10 +2298,8 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( approverTime = stockTakeRecord.approverTime, ) } - - // Apply pagination - if pageSize is Int.MAX_VALUE, return all results + val paginatedResult = if (pageSize >= allResults.size) { - // Return all results if pageSize is large enough allResults } else { val pageable = PageRequest.of(pageNum, pageSize) @@ -1954,7 +2311,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( emptyList() } } - + return RecordsRes(paginatedResult, allResults.size) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt index 55a8089..60f3ae2 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt @@ -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 { - logger.info("--------- Start - Create Stock Take for Sections -------") + log.info("--------- Start - Create Stock Take for Sections -------") val result = mutableMapOf() @@ -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 } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt index a216721..6060b86 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt @@ -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 } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt index cdef5d3..61cc04d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt @@ -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 { - // 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 { - 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 { + return stockOutRecordService.getInventoryLotDetailsByStockTakeSection( + stockTakeSection, + stockTakeId, + stockTakeRoundId, + pageNum, + pageSize + ) + } @PostMapping("/saveStockTakeRecord") fun saveStockTakeRecord( diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt index 9ce611c..b973363 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt @@ -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, diff --git a/src/main/resources/db/changelog/changes/20260405_01_Enson/01_alter_stock_take.sql b/src/main/resources/db/changelog/changes/20260405_01_Enson/01_alter_stock_take.sql new file mode 100644 index 0000000..f62f711 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260405_01_Enson/01_alter_stock_take.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql +-- changeset Enson:alter_stock_take_line + +ALTER TABLE `fpsmsdb`.`stock_take_line` +Add COLUMN `stockTakeRecordId` INT; + + + From e9a7f06ea151b397783659679264c5173dd07413 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 8 Apr 2026 22:18:52 +0800 Subject: [PATCH 2/2] update --- .../jobOrder/service/JoPickOrderService.kt | 10 +++++--- .../jobOrder/web/JobOrderController.kt | 4 ++-- .../ffii/fpsms/modules/master/entity/Bom.kt | 2 ++ .../service/ProductProcessService.kt | 24 ++++++++++--------- .../web/ProductProcessController.kt | 8 +++---- .../20260408_01_Enson/02_alter_stock_take.sql | 9 +++++++ .../20260408_01_Enson/03_alter_stock_take.sql | 15 ++++++++++++ 7 files changed, 52 insertions(+), 20 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260408_01_Enson/02_alter_stock_take.sql create mode 100644 src/main/resources/db/changelog/changes/20260408_01_Enson/03_alter_stock_take.sql diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt index 2711e91..79a011e 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt @@ -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 { +open fun getAllJoPickOrders(bomType: String?, floor: String?): List { println("=== getAllJoPickOrders ===") return try { @@ -1879,8 +1879,12 @@ open fun getAllJoPickOrders(isDrink: Boolean?, floor: String?): List { - return joPickOrderService.getAllJoPickOrders(isDrink, floor) + return joPickOrderService.getAllJoPickOrders(bomType, floor) } @GetMapping("/all-lots-hierarchical-by-pick-order/{pickOrderId}") diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt index f230bc3..fd094e8 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt @@ -28,6 +28,8 @@ open class Bom : BaseEntity() { @Column open var allergicSubstances: Int? = null + @Column + open var type: String? = null @Column open var timeSequence: Int? = null @Column diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index 464e406..0218cac 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -1407,11 +1407,15 @@ open class ProductProcessService( ) } - open fun getAllJoborderProductProcessInfo(isDrink: Boolean?): List { + open fun getAllJoborderProductProcessInfo(bomType: String?): List { 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?, 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 diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt index f9177f8..90b7a49 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt @@ -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 { - 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 ) diff --git a/src/main/resources/db/changelog/changes/20260408_01_Enson/02_alter_stock_take.sql b/src/main/resources/db/changelog/changes/20260408_01_Enson/02_alter_stock_take.sql new file mode 100644 index 0000000..e784535 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260408_01_Enson/02_alter_stock_take.sql @@ -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); + diff --git a/src/main/resources/db/changelog/changes/20260408_01_Enson/03_alter_stock_take.sql b/src/main/resources/db/changelog/changes/20260408_01_Enson/03_alter_stock_take.sql new file mode 100644 index 0000000..0625b31 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260408_01_Enson/03_alter_stock_take.sql @@ -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` = ''; \ No newline at end of file