CANCERYS\kw093 преди 5 часа
родител
ревизия
5954b9588d
променени са 13 файла, в които са добавени 896 реда и са изтрити 334 реда
  1. +25
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRepository.kt
  2. +5
    -3
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  3. +1
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt
  4. +196
    -75
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt
  5. +1
    -4
      src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt
  6. +11
    -1
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLine.kt
  7. +1
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt
  8. +576
    -219
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  9. +29
    -18
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt
  10. +1
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt
  11. +20
    -12
      src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt
  12. +22
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt
  13. +8
    -0
      src/main/resources/db/changelog/changes/20260405_01_Enson/01_alter_stock_take.sql

+ 25
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoPickOrderRepository.kt Целия файл

@@ -54,4 +54,29 @@ fun findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn(
requiredDeliveryDate: LocalDate, requiredDeliveryDate: LocalDate,
ticketStatus: List<DoPickOrderStatus> ticketStatus: List<DoPickOrderStatus>
): List<DoPickOrder> ): List<DoPickOrder>

/**
* Batch release: existing ticket same shop, same floor ([storeId]), same delivery date, still active.
* [storeId] null means default-truck / XF-style row (matches NULL store_id on entity).
*/
@Query(
"""
SELECT d FROM DoPickOrder d
WHERE d.deleted = false
AND d.releaseType = 'batch'
AND d.shopId = :shopId
AND d.requiredDeliveryDate = :requiredDate
AND d.ticketStatus IN :statuses
AND (
(:storeId IS NULL AND d.storeId IS NULL)
OR (d.storeId = :storeId)
)
"""
)
fun findMergeableBatchDoPickOrders(
@Param("shopId") shopId: Long,
@Param("storeId") storeId: String?,
@Param("requiredDate") requiredDate: LocalDate,
@Param("statuses") statuses: List<DoPickOrderStatus>,
): List<DoPickOrder>
} }

+ 5
- 3
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt Целия файл

@@ -1519,6 +1519,7 @@ open class DeliveryOrderService(
val suggestions = suggestedPickLotService.suggestionForPickOrderLines( val suggestions = suggestedPickLotService.suggestionForPickOrderLines(
SuggestedPickLotForPolRequest(pickOrderLines = lines) SuggestedPickLotForPolRequest(pickOrderLines = lines)
) )

val saveSuggestedPickLots = suggestedPickLotService.saveAll(suggestions.suggestedList) val saveSuggestedPickLots = suggestedPickLotService.saveAll(suggestions.suggestedList)
val insufficientCount = suggestions.suggestedList.count { it.suggestedLotLine == null } val insufficientCount = suggestions.suggestedList.count { it.suggestedLotLine == null }
if (insufficientCount > 0) { if (insufficientCount > 0) {
@@ -1538,7 +1539,8 @@ open class DeliveryOrderService(
} }
} }
inventoryLotLineRepository.saveAll(inventoryLotLines) inventoryLotLineRepository.saveAll(inventoryLotLines)

val pickOrderLineMap = lines.associateBy { it.id }
val inventoryLotLineMap = inventoryLotLines.associateBy { it.id }
// No-lot (insufficient stock) lines are created in suggestedPickLotService.saveAll → createStockOutLineForSuggestion; skip here to avoid duplicates. // No-lot (insufficient stock) lines are created in suggestedPickLotService.saveAll → createStockOutLineForSuggestion; skip here to avoid duplicates.
saveSuggestedPickLots.forEach { lot -> saveSuggestedPickLots.forEach { lot ->
@@ -1546,8 +1548,8 @@ open class DeliveryOrderService(
val polId = lot.pickOrderLine?.id val polId = lot.pickOrderLine?.id
val illId = lot.suggestedLotLine?.id val illId = lot.suggestedLotLine?.id
if (polId != null) { if (polId != null) {
val pickOrderLine = pickOrderLineRepository.findById(polId).orElse(null)
val inventoryLotLine = illId?.let { inventoryLotLineRepository.findById(it).orElse(null) }
val pickOrderLine = pickOrderLineMap[polId]
val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }


if (pickOrderLine != null) { if (pickOrderLine != null) {
val line = StockOutLine().apply { val line = StockOutLine().apply {


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt Целия файл

@@ -444,7 +444,7 @@ open class DoPickOrderService(
.mapValues { (_, entries) -> .mapValues { (_, entries) ->
entries.map { it.value } entries.map { it.value }
.filter { it.unassigned > 0 } .filter { it.unassigned > 0 }
.sortedByDescending { it.unassigned }
.sortedBy { it.truckLanceCode }
} }
.filterValues { lanes -> lanes.any { it.unassigned > 0 } } .filterValues { lanes -> lanes.any { it.unassigned > 0 } }
.toSortedMap(compareBy { it }) .toSortedMap(compareBy { it })


+ 196
- 75
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 org.springframework.stereotype.Service
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
import java.sql.SQLException
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.Semaphore
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.min import kotlin.math.min
import com.ffii.core.support.JdbcDao import com.ffii.core.support.JdbcDao
@@ -27,6 +29,59 @@ import com.ffii.fpsms.modules.user.entity.UserRepository
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository
import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus
import jakarta.persistence.OptimisticLockException
import org.hibernate.StaleObjectStateException
import org.springframework.orm.ObjectOptimisticLockingFailureException

/**
* Batch DO release: retry [RELEASE_RETRY_MAX] times when Hibernate optimistic locking fails
* on shared [com.ffii.fpsms.modules.stock.entity.InventoryLotLine] rows (concurrent suggest/hold).
*
* **How to reproduce contention (manual / test ideas):**
* - Run two batch releases in parallel (two browsers or two API clients) that share hot lot lines; or
* - Release truck B then immediately truck C with DOs that suggest the same `inventory_lot_line`; or
* - Stress: many DOs in one batch all touching the same few lot lines.
*
* **Multiple browser tabs:** each tab starts a separate async job. Without a gate, up to [pool] threads
* run batch releases in parallel and hammer the same `inventory_lot_line` rows → optimistic locks + deadlocks.
* [batchReleaseConcurrencyGate] serializes batch jobs (per JVM) so trucks are released one job at a time.
*/
private const val RELEASE_RETRY_MAX = 3

private fun isOptimisticLockFailure(t: Throwable?): Boolean {
var c: Throwable? = t
while (c != null) {
when (c) {
is StaleObjectStateException -> return true
is OptimisticLockException -> return true
is ObjectOptimisticLockingFailureException -> return true
}
if (c.message?.contains("Row was updated or deleted by another transaction", ignoreCase = true) == true) {
return true
}
c = c.cause
}
return false
}

/** MySQL deadlock / SQLSTATE 40001 — safe to retry like optimistic lock. */
private fun isDeadlockFailure(t: Throwable?): Boolean {
var c: Throwable? = t
while (c != null) {
if (c.message?.contains("Deadlock", ignoreCase = true) == true) return true
if (c.message?.contains("try restarting transaction", ignoreCase = true) == true) return true
(c as? SQLException)?.let { sql ->
if (sql.sqlState == "40001") return true
if (sql.errorCode == 1213) return true // ER_LOCK_DEADLOCK
}
c = c.cause
}
return false
}

private fun isRetriableConcurrencyFailure(t: Throwable?): Boolean =
isOptimisticLockFailure(t) || isDeadlockFailure(t)

data class BatchReleaseJobStatus( data class BatchReleaseJobStatus(
val jobId: String, val jobId: String,
val total: Int, val total: Int,
@@ -51,6 +106,8 @@ class DoReleaseCoordinatorService(
) { ) {
private val poolSize = Runtime.getRuntime().availableProcessors() private val poolSize = Runtime.getRuntime().availableProcessors()
private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) private val executor = Executors.newFixedThreadPool(min(poolSize, 4))
/** Only one batch-release job runs at a time (multiple tabs queue here). */
private val batchReleaseConcurrencyGate = Semaphore(1, true)
private val jobs = ConcurrentHashMap<String, BatchReleaseJobStatus>() private val jobs = ConcurrentHashMap<String, BatchReleaseJobStatus>()
private fun getDayOfWeekAbbr(date: LocalDate?): String? { private fun getDayOfWeekAbbr(date: LocalDate?): String? {
if (date == null) return null if (date == null) return null
@@ -460,8 +517,10 @@ class DoReleaseCoordinatorService(
jobs[jobId] = status jobs[jobId] = status


executor.submit { executor.submit {
batchReleaseConcurrencyGate.acquireUninterruptibly()
try { try {
println("📦 Starting batch release for ${ids.size} orders")
try {
println("Starting batch release for ${ids.size} orders (job $jobId)")
val sortedIds = getOrderedDeliveryOrderIds(ids) val sortedIds = getOrderedDeliveryOrderIds(ids)
println(" DEBUG: Got ${sortedIds.size} sorted orders") println(" DEBUG: Got ${sortedIds.size} sorted orders")


@@ -478,18 +537,52 @@ class DoReleaseCoordinatorService(
println("⏭️ DO $id is already ${deliveryOrder.status?.value}, skipping") println("⏭️ DO $id is already ${deliveryOrder.status?.value}, skipping")
return@forEach return@forEach
} }
val result = deliveryOrderService.releaseDeliveryOrderWithoutTicket(
ReleaseDoRequest(id = id, userId = userId)
)
releaseResults.add(result)
status.success.incrementAndGet()
println(" DO $id -> Success")
var result: ReleaseDoResult? = null
for (attempt in 1..RELEASE_RETRY_MAX) {
try {
result = deliveryOrderService.releaseDeliveryOrderWithoutTicket(
ReleaseDoRequest(id = id, userId = userId)
)
break
} catch (e: Exception) {
if (isRetriableConcurrencyFailure(e) && attempt < RELEASE_RETRY_MAX) {
val kind = when {
isDeadlockFailure(e) -> "deadlock/DB lock"
else -> "optimistic lock"
}
println(
"⚠️ DO $id $kind (attempt $attempt/$RELEASE_RETRY_MAX), retrying after short backoff..."
)
try {
Thread.sleep(50L * attempt)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
throw e
}
} else {
throw e
}
}
}
if (result != null) {
releaseResults.add(result)
status.success.incrementAndGet()
println(" DO $id -> Success")
} else {
throw IllegalStateException("DO $id release returned null after $RELEASE_RETRY_MAX attempts")
}
} catch (e: Exception) { } catch (e: Exception) {
synchronized(status.failed) { synchronized(status.failed) {
status.failed.add(id to (e.message ?: "Exception")) status.failed.add(id to (e.message ?: "Exception"))
} }
println("❌ DO $id skipped: ${e.message}") println("❌ DO $id skipped: ${e.message}")


// Transient concurrency: avoid creating one issue per DO line (noise)
if (isRetriableConcurrencyFailure(e)) {
println("⚠️ DO $id: skipping batch-release issues for transient concurrency failure")
return@forEach
}

// 调用 PickExecutionIssueService 创建 issue 记录 // 调用 PickExecutionIssueService 创建 issue 记录
try { try {
val issueCategory = when { val issueCategory = when {
@@ -538,7 +631,7 @@ class DoReleaseCoordinatorService(
grouped.forEach { (key, group) -> grouped.forEach { (key, group) ->
try { try {
createMergedDoPickOrder(group) createMergedDoPickOrder(group)
println(" DEBUG: Created DoPickOrder for ${group.size} DOs")
println(" DEBUG: Merged/created DoPickOrder for ${group.size} DO(s)")
} catch (e: Exception) { } catch (e: Exception) {
println("❌ Error creating DoPickOrder: ${e.message}") println("❌ Error creating DoPickOrder: ${e.message}")
e.printStackTrace() e.printStackTrace()
@@ -553,97 +646,125 @@ class DoReleaseCoordinatorService(


println(" Batch completed: ${status.success.get()} success, ${status.failed.size} failed") println(" Batch completed: ${status.success.get()} success, ${status.failed.size} failed")


} catch (e: Exception) {
println("❌ Batch release exception: ${e.javaClass.simpleName} - ${e.message}")
e.printStackTrace()
} finally {
status.running = false
status.finishedAt = Instant.now().toEpochMilli()
}
}
} catch (e: Exception) {
println("❌ Batch release exception: ${e.javaClass.simpleName} - ${e.message}")
e.printStackTrace()
} finally {
status.running = false
status.finishedAt = Instant.now().toEpochMilli()
}
} finally {
batchReleaseConcurrencyGate.release()
}
}


return MessageResponse(
id = null, code = "STARTED", name = null, type = null,
message = "Batch release started", errorPosition = null,
entity = mapOf("jobId" to jobId, "total" to ids.size)
)
}
return MessageResponse(
id = null, code = "STARTED", name = null, type = null,
message = "Batch release started", errorPosition = null,
entity = mapOf("jobId" to jobId, "total" to ids.size)
)
}


// 替换第 627-653 行
private fun createMergedDoPickOrder(results: List<ReleaseDoResult>) {
val first = results.first()
val storeId: String? = when {
first.usedDefaultTruck!=false -> null
else -> when (first.preferredFloor) {
"2F" -> "2/F"
"4F" -> "4/F"
else -> "2/F"
}
private fun newMergedDoPickOrderEntity(first: ReleaseDoResult, storeId: String?): DoPickOrder {
return DoPickOrder(
storeId = storeId,
ticketNo = "TEMP-${System.currentTimeMillis()}",
ticketStatus = DoPickOrderStatus.pending,
truckId = first.truckId,
pickOrderId = null,
doOrderId = null,
ticketReleaseTime = null,
shopId = first.shopId,
handlerName = null,
handledBy = null,
ticketCompleteDateTime = null,
truckDepartureTime = first.truckDepartureTime,
truckLanceCode = first.truckLanceCode,
shopCode = first.shopCode,
shopName = first.shopName,
requiredDeliveryDate = first.estimatedArrivalDate,
createdBy = null,
modifiedBy = null,
pickOrderCode = null,
deliveryOrderCode = null,
loadingSequence = first.loadingSequence,
releaseType = "batch"
)
} }
val doPickOrder = DoPickOrder(
storeId = storeId,
ticketNo = "TEMP-${System.currentTimeMillis()}",
ticketStatus = DoPickOrderStatus.pending,
truckId = first.truckId,
pickOrderId = null,
doOrderId = null,
ticketReleaseTime = null,
shopId = first.shopId,
handlerName = null,
handledBy = null,
ticketCompleteDateTime = null,
truckDepartureTime = first.truckDepartureTime,
truckLanceCode = first.truckLanceCode,
shopCode = first.shopCode,
shopName = first.shopName,
requiredDeliveryDate = first.estimatedArrivalDate,
createdBy = null,
modifiedBy = null,
pickOrderCode = null,
deliveryOrderCode = null,
loadingSequence = first.loadingSequence,
releaseType = "batch"
)
// 直接使用 doPickOrderRepository.save() 而不是 doPickOrderService.save()
val saved = doPickOrderRepository.save(doPickOrder)
println(" DEBUG: Saved DoPickOrder - ID: ${saved.id}, Ticket: ${saved.ticketNo}")
// 创建多条 DoPickOrderLine(每个 DO 一条)

/**
* If a batch ticket already exists for the same shop, floor ([storeId]), delivery date, and is still
* pending or released, add new DO lines onto it instead of creating another do_pick_order.
*/
private fun createMergedDoPickOrder(results: List<ReleaseDoResult>) {
val first = results.first()

val storeId: String? = when {
first.usedDefaultTruck != false -> null
else -> when (first.preferredFloor) {
"2F" -> "2/F"
"4F" -> "4/F"
else -> "2/F"
}
}

val target: DoPickOrder =
if (first.shopId != null && first.estimatedArrivalDate != null) {
val candidates = doPickOrderRepository.findMergeableBatchDoPickOrders(
shopId = first.shopId!!,
storeId = storeId,
requiredDate = first.estimatedArrivalDate!!,
statuses = listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released),
)
val existing = candidates.firstOrNull { c ->
c.truckId == first.truckId &&
c.truckDepartureTime == first.truckDepartureTime &&
c.truckLanceCode == first.truckLanceCode
} ?: candidates.minByOrNull { it.id ?: Long.MAX_VALUE }
if (existing != null) {
println(
" DEBUG: Merging batch into existing DoPickOrder id=${existing.id}, ticket=${existing.ticketNo} " +
"(shop=${first.shopId}, storeId=$storeId, date=${first.estimatedArrivalDate})"
)
existing
} else {
doPickOrderRepository.save(newMergedDoPickOrderEntity(first, storeId))
}
} else {
doPickOrderRepository.save(newMergedDoPickOrderEntity(first, storeId))
}

println(" DEBUG: Target DoPickOrder - ID: ${target.id}, Ticket: ${target.ticketNo}")

results.forEach { result -> results.forEach { result ->
val existingLines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(result.pickOrderId) val existingLines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(result.pickOrderId)
if (existingLines.isNotEmpty()) { if (existingLines.isNotEmpty()) {
println("⚠️ WARNING: pick_order ${result.pickOrderId} already in do_pick_order_line, skipping") println("⚠️ WARNING: pick_order ${result.pickOrderId} already in do_pick_order_line, skipping")
return@forEach // 跳过这个
return@forEach
} }
// 先创建 DoPickOrderLine,然后检查库存问题

val line = DoPickOrderLine().apply { val line = DoPickOrderLine().apply {
doPickOrderId = saved.id
doPickOrderId = target.id
pickOrderId = result.pickOrderId pickOrderId = result.pickOrderId
doOrderId = result.deliveryOrderId doOrderId = result.deliveryOrderId
pickOrderCode = result.pickOrderCode pickOrderCode = result.pickOrderCode
deliveryOrderCode = result.deliveryOrderCode deliveryOrderCode = result.deliveryOrderCode
status = "pending" // 初始状态
status = "pending"
} }
doPickOrderLineRepository.save(line) doPickOrderLineRepository.save(line)
println(" DEBUG: Created DoPickOrderLine for pick order ${result.pickOrderId}") println(" DEBUG: Created DoPickOrderLine for pick order ${result.pickOrderId}")
} }
// 现在检查整个 DoPickOrder 是否有库存问题
val hasStockIssues = checkPickOrderHasStockIssues(saved.id!!)

val hasStockIssues = checkPickOrderHasStockIssues(target.id!!)
if (hasStockIssues) { if (hasStockIssues) {
// 更新所有相关的 DoPickOrderLine 状态为 "issue"
val doPickOrderLines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(saved.id!!)
val doPickOrderLines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(target.id!!)
doPickOrderLines.forEach { line -> doPickOrderLines.forEach { line ->
line.status = "issue" line.status = "issue"
} }
doPickOrderLineRepository.saveAll(doPickOrderLines) doPickOrderLineRepository.saveAll(doPickOrderLines)
println(" DEBUG: Updated ${doPickOrderLines.size} DoPickOrderLine records to 'issue' status") println(" DEBUG: Updated ${doPickOrderLines.size} DoPickOrderLine records to 'issue' status")
} }
println(" DEBUG: Created ${results.size} DoPickOrderLine records") println(" DEBUG: Created ${results.size} DoPickOrderLine records")
} }
private fun checkPickOrderHasStockIssues(doPickOrderId: Long): Boolean { private fun checkPickOrderHasStockIssues(doPickOrderId: Long): Boolean {


+ 1
- 4
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 帶入)。 * 期初/累計區間取該輪紀錄之 MIN(`date`)~MAX(`date`)(與 V1 之 in/out 聚合邏輯一致,但以 rb 帶入)。
* 「審核時間」欄位:`approverTime` 之本地日期時間字串;無則退回盤點日 `date`。 * 「審核時間」欄位:`approverTime` 之本地日期時間字串;無則退回盤點日 `date`。
*/ */
@@ -309,7 +309,6 @@ ORDER BY
SELECT COUNT(*) AS c FROM stocktakerecord s SELECT COUNT(*) AS c FROM stocktakerecord s
WHERE s.deleted = 0 WHERE s.deleted = 0
AND s.stockTakeRoundId = :stockTakeRoundId AND s.stockTakeRoundId = :stockTakeRoundId
AND s.status = 'completed'
""".trimIndent() """.trimIndent()
val cntRow = jdbcDao.queryForList( val cntRow = jdbcDao.queryForList(
countSql, countSql,
@@ -335,7 +334,6 @@ WITH rb AS (
FROM stocktakerecord s FROM stocktakerecord s
WHERE s.deleted = 0 WHERE s.deleted = 0
AND s.stockTakeRoundId = :stockTakeRoundId AND s.stockTakeRoundId = :stockTakeRoundId
AND s.status = 'completed'
), ),
latest_str AS ( latest_str AS (
SELECT SELECT
@@ -350,7 +348,6 @@ latest_str AS (
FROM stocktakerecord str FROM stocktakerecord str
WHERE str.deleted = 0 WHERE str.deleted = 0
AND str.stockTakeRoundId = :stockTakeRoundId AND str.stockTakeRoundId = :stockTakeRoundId
AND str.status = 'completed'
), ),
in_agg AS ( in_agg AS (
SELECT SELECT


+ 11
- 1
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.master.entity.UomConversion
import com.ffii.fpsms.modules.stock.enums.StockTakeLineStatus import com.ffii.fpsms.modules.stock.enums.StockTakeLineStatus
import com.ffii.fpsms.modules.stock.enums.StockTakeLineStatusConverter import com.ffii.fpsms.modules.stock.enums.StockTakeLineStatusConverter
import jakarta.persistence.*
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import jakarta.persistence.Convert
import jakarta.persistence.Column
import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size import jakarta.validation.constraints.Size
import java.math.BigDecimal import java.math.BigDecimal
@@ -44,4 +50,8 @@ open class StockTakeLine : BaseEntity<Long>() {
@Size(max = 500) @Size(max = 500)
@Column(name = "remarks", length = 500) @Column(name = "remarks", length = 500)
open var remarks: String? = null open var remarks: String? = null

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "stockTakeRecordId")
open var stockTakeRecord: StockTakeRecord? = null
} }

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt Целия файл

@@ -8,6 +8,7 @@ import java.io.Serializable
interface StockTakeLineRepository : AbstractRepository<StockTakeLine, Long> { interface StockTakeLineRepository : AbstractRepository<StockTakeLine, Long> {
fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?; fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?;
fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?; fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?;
fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine?
fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse( fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse(
stockTakeIds: Collection<Long>, stockTakeIds: Collection<Long>,
inventoryLotLineIds: Collection<Long> inventoryLotLineIds: Collection<Long>


+ 576
- 219
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
Файловите разлики са ограничени, защото са твърде много
Целия файл


+ 29
- 18
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.math.BigDecimal
import java.time.LocalDateTime import java.time.LocalDateTime
import org.springframework.context.annotation.Lazy import org.springframework.context.annotation.Lazy
import org.springframework.transaction.annotation.Transactional


@Service @Service
class StockTakeService(
open class StockTakeService(
val stockTakeRepository: StockTakeRepository, val stockTakeRepository: StockTakeRepository,
val warehouseRepository: WarehouseRepository, val warehouseRepository: WarehouseRepository,
val warehouseService: WarehouseService, val warehouseService: WarehouseService,
@@ -35,9 +36,11 @@ class StockTakeService(
@Lazy val itemUomService: ItemUomService, @Lazy val itemUomService: ItemUomService,
val stockTakeLineService: StockTakeLineService, val stockTakeLineService: StockTakeLineService,
val inventoryLotLineRepository: InventoryLotLineRepository, val inventoryLotLineRepository: InventoryLotLineRepository,
val stockTakeRecordService: StockTakeRecordService,
) { ) {
val logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java)

companion object {
private val log = LoggerFactory.getLogger(StockTakeService::class.java)
}
fun assignStockTakeNo(): String { fun assignStockTakeNo(): String {
val prefix = "ST" val prefix = "ST"
val midfix = CodeGenerator.DEFAULT_MIDFIX val midfix = CodeGenerator.DEFAULT_MIDFIX
@@ -76,10 +79,10 @@ class StockTakeService(
}.toString() }.toString()
} }
fun importExcel(workbook: Workbook?): String { fun importExcel(workbook: Workbook?): String {
logger.info("--------- Start - Import Stock Take Excel -------");
log.info("--------- Start - Import Stock Take Excel -------");


if (workbook == null) { if (workbook == null) {
logger.error("No Excel Import");
log.error("No Excel Import");
return "Import Excel failure"; return "Import Excel failure";
} }


@@ -115,7 +118,7 @@ class StockTakeService(
stockTakeId = savedStockTake.id stockTakeId = savedStockTake.id
) )
val savedStockIn = stockInService.create(saveStockInReq) val savedStockIn = stockInService.create(saveStockInReq)
logger.info("Last row: ${sheet.lastRowNum}");
log.info("Last row: ${sheet.lastRowNum}");
for (i in START_ROW_INDEX ..< sheet.lastRowNum) { for (i in START_ROW_INDEX ..< sheet.lastRowNum) {
val row = sheet.getRow(i) val row = sheet.getRow(i)


@@ -126,7 +129,7 @@ class StockTakeService(
val code = getCellStringValue(row.getCell(COLUMN_WAREHOSE_INDEX)) val code = getCellStringValue(row.getCell(COLUMN_WAREHOSE_INDEX))
val zone = getCellStringValue(row.getCell(COLUMN_ZONE_INDEX)) val zone = getCellStringValue(row.getCell(COLUMN_ZONE_INDEX))
val slot = getCellStringValue(row.getCell(COLUMN_SLOT_INDEX)) val slot = getCellStringValue(row.getCell(COLUMN_SLOT_INDEX))
// logger.info("Warehouse code - zone - slot: ${row.getCell(COLUMN_WAREHOSE_INDEX).cellType} - ${row.getCell(COLUMN_ZONE_INDEX).cellType} - ${row.getCell(COLUMN_SLOT_INDEX).cellType}")
// log.info("Warehouse code - zone - slot: ${row.getCell(COLUMN_WAREHOSE_INDEX).cellType} - ${row.getCell(COLUMN_ZONE_INDEX).cellType} - ${row.getCell(COLUMN_SLOT_INDEX).cellType}")


val defaultCapacity = BigDecimal(10000) val defaultCapacity = BigDecimal(10000)
val warehouseCode = "$code-$zone-$slot" val warehouseCode = "$code-$zone-$slot"
@@ -145,26 +148,26 @@ class StockTakeService(
warehouseService.saveWarehouse(warehouseRequest) warehouseService.saveWarehouse(warehouseRequest)
} }
} catch (e: Exception) { } catch (e: Exception) {
logger.error("Import Error (Warehouse Error): ${e.message}")
log.error("Import Error (Warehouse Error): ${e.message}")
null null
} ?: continue } ?: continue


// Item // Item
val item = try { val item = try {
// logger.info("Item Type: ${row.getCell(COLUMN_ITEM_CODE_INDEX).cellType}")
// log.info("Item Type: ${row.getCell(COLUMN_ITEM_CODE_INDEX).cellType}")
val itemCode = row.getCell(COLUMN_ITEM_CODE_INDEX).stringCellValue; val itemCode = row.getCell(COLUMN_ITEM_CODE_INDEX).stringCellValue;
itemsService.findByCode(itemCode) itemsService.findByCode(itemCode)
} catch (e: Exception) { } catch (e: Exception) {
logger.error("Import Error (Item Code Error): ${e.message}")
log.error("Import Error (Item Code Error): ${e.message}")
null null
} ?: continue } ?: continue


// Stock Take Line (First Create) // Stock Take Line (First Create)
val qty = try { val qty = try {
// logger.info("Qty Type: ${row.getCell(COLUMN_QTY_INDEX).cellType}")
// log.info("Qty Type: ${row.getCell(COLUMN_QTY_INDEX).cellType}")
row.getCell(COLUMN_QTY_INDEX).numericCellValue.toBigDecimal() row.getCell(COLUMN_QTY_INDEX).numericCellValue.toBigDecimal()
} catch (e: Exception) { } catch (e: Exception) {
logger.error("Import Error (Qty Error): ${e.message}")
log.error("Import Error (Qty Error): ${e.message}")
null null
} ?: continue } ?: continue


@@ -218,7 +221,7 @@ class StockTakeService(
inventoryLotLineId = inventoryLotLine?.id inventoryLotLineId = inventoryLotLine?.id
} }
stockTakeLineService.saveStockTakeLine(saveStockTakeLineReq) stockTakeLineService.saveStockTakeLine(saveStockTakeLineReq)
logger.info("[Stock Take]: Saved item '${item.name}' to warehouse '${warehouse.code}'")
log.info("[Stock Take]: Saved item '${item.name}' to warehouse '${warehouse.code}'")
} }


// End of Import // End of Import
@@ -229,7 +232,7 @@ class StockTakeService(
status = StockTakeStatus.COMPLETED.value status = StockTakeStatus.COMPLETED.value
} }
saveStockTake(saveStockTakeReq) saveStockTake(saveStockTakeReq)
logger.info("--------- End - Import Stock Take Excel -------")
log.info("--------- End - Import Stock Take Excel -------")
return "Import Excel success"; return "Import Excel success";
} }


@@ -237,7 +240,7 @@ class StockTakeService(




fun createStockTakeForSections(): Map<String, String> { fun createStockTakeForSections(): Map<String, String> {
logger.info("--------- Start - Create Stock Take for Sections -------")
log.info("--------- Start - Create Stock Take for Sections -------")
val result = mutableMapOf<String, String>() val result = mutableMapOf<String, String>()
@@ -253,6 +256,7 @@ class StockTakeService(
val batchPlanEnd = batchPlanStart.plusDays(1) val batchPlanEnd = batchPlanStart.plusDays(1)
// 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4) // 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4)
val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1
var placeholderStockTakerId: Long? = null
distinctSections.forEach { section -> distinctSections.forEach { section ->
try { try {
val code = assignStockTakeNo() val code = assignStockTakeNo()
@@ -268,14 +272,21 @@ class StockTakeService(
stockTakeRoundId = roundId stockTakeRoundId = roundId
) )
val savedStockTake = saveStockTake(saveStockTakeReq) val savedStockTake = saveStockTake(saveStockTakeReq)
if (placeholderStockTakerId == null) {
placeholderStockTakerId = stockTakeRecordService.resolvePlaceholderStockTakerId()
}
stockTakeRecordService.createPlaceholderStockTakeRecordsForStockTake(
savedStockTake,
placeholderStockTakerId!!
)
result[section] = "Created: ${savedStockTake.code}" result[section] = "Created: ${savedStockTake.code}"
logger.info("Created stock take for section $section: ${savedStockTake.code}, roundId=$roundId")
log.info("Created stock take for section $section: ${savedStockTake.code}, roundId=$roundId")
} catch (e: Exception) { } catch (e: Exception) {
result[section] = "Error: ${e.message}" result[section] = "Error: ${e.message}"
logger.error("Error creating stock take for section $section: ${e.message}")
log.error("Error creating stock take for section $section: ${e.message}")
} }
} }
logger.info("--------- End - Create Stock Take for Sections -------")
log.info("--------- End - Create Stock Take for Sections -------")
return result return result
} }
} }

+ 1
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt Целия файл

@@ -540,7 +540,7 @@ open class SuggestedPickLotService(
*/ */
val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(pickOrder.consoCode ?: "") val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(pickOrder.consoCode ?: "")
if (stockOut == null) { if (stockOut == null) {
println("⚠️ StockOut not found for consoCode=${pickOrder.consoCode}, skip creating stockOutLine")
println(" StockOut not found for consoCode=${pickOrder.consoCode}, skip creating stockOutLine")
return null return null
} }




+ 20
- 12
src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt Целия файл

@@ -127,15 +127,16 @@ class StockTakeRecordController(
fun getInventoryLotDetailsByStockTakeSectionNotMatch( fun getInventoryLotDetailsByStockTakeSectionNotMatch(
@RequestParam stockTakeSection: String, @RequestParam stockTakeSection: String,
@RequestParam(required = false) stockTakeId: Long?, @RequestParam(required = false) stockTakeId: Long?,
@RequestParam(required = false) stockTakeRoundId: Long?,
@RequestParam(required = false, defaultValue = "0") pageNum: Int, @RequestParam(required = false, defaultValue = "0") pageNum: Int,
@RequestParam(required = false) pageSize: Int? @RequestParam(required = false) pageSize: Int?
): RecordsRes<InventoryLotDetailResponse> { ): RecordsRes<InventoryLotDetailResponse> {
// If pageSize is null, use a large number to return all records
val actualPageSize = pageSize ?: Int.MAX_VALUE val actualPageSize = pageSize ?: Int.MAX_VALUE
return stockOutRecordService.getInventoryLotDetailsByStockTakeSectionNotMatch( return stockOutRecordService.getInventoryLotDetailsByStockTakeSectionNotMatch(
stockTakeSection,
stockTakeId,
pageNum,
stockTakeSection,
stockTakeId,
stockTakeRoundId,
pageNum,
actualPageSize actualPageSize
) )
} }
@@ -148,14 +149,21 @@ class StockTakeRecordController(
} }
@GetMapping("/inventoryLotDetailsBySection") @GetMapping("/inventoryLotDetailsBySection")
fun getInventoryLotDetailsByStockTakeSection(
@RequestParam stockTakeSection: String,
@RequestParam(required = false) stockTakeId: Long?,
@RequestParam(required = false, defaultValue = "0") pageNum: Int,
@RequestParam(required = false, defaultValue = "10") pageSize: Int
): RecordsRes<InventoryLotDetailResponse> {
return stockOutRecordService.getInventoryLotDetailsByStockTakeSection(stockTakeSection, stockTakeId, pageNum, pageSize)
}
fun getInventoryLotDetailsByStockTakeSection(
@RequestParam stockTakeSection: String,
@RequestParam(required = false) stockTakeId: Long?,
@RequestParam(required = false) stockTakeRoundId: Long?,
@RequestParam(required = false, defaultValue = "0") pageNum: Int,
@RequestParam(required = false, defaultValue = "10") pageSize: Int
): RecordsRes<InventoryLotDetailResponse> {
return stockOutRecordService.getInventoryLotDetailsByStockTakeSection(
stockTakeSection,
stockTakeId,
stockTakeRoundId,
pageNum,
pageSize
)
}
@PostMapping("/saveStockTakeRecord") @PostMapping("/saveStockTakeRecord")
fun saveStockTakeRecord( fun saveStockTakeRecord(


+ 22
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt Целия файл

@@ -82,6 +82,28 @@ data class SaveStockTakeRecordRequest(
// val stockTakerName: String, // val stockTakerName: String,
val remark: String? = null val remark: String? = null
) )

/**
* 盤點員單筆儲存成功回傳。勿直接回傳 [StockTakeRecord] Entity,否則 Jackson 序列化 warehouse/stockTake 等關聯時
* 易因 Hibernate 代理/Session 已關閉導致 HTTP 500(controller 的 try/catch 無法攔截序列化階段錯誤)。
*/
data class SaveStockTakeRecordResponse(
val id: Long?,
val version: Int?,
val itemId: Long?,
val lotId: Long?,
val inventoryLotId: Long?,
val warehouseId: Long?,
val stockTakeId: Long?,
val stockTakeRoundId: Long?,
val status: String?,
val bookQty: BigDecimal?,
val varianceQty: BigDecimal?,
val pickerFirstStockTakeQty: BigDecimal?,
val pickerFirstBadQty: BigDecimal?,
val pickerSecondStockTakeQty: BigDecimal?,
val pickerSecondBadQty: BigDecimal?,
)
data class SaveApproverStockTakeRecordRequest( data class SaveApproverStockTakeRecordRequest(
val stockTakeRecordId: Long? = null, val stockTakeRecordId: Long? = null,
val qty: BigDecimal, val qty: BigDecimal,


+ 8
- 0
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;




Зареждане…
Отказ
Запис