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