ソースを参照

update

master^2
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,
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>
}

+ 5
- 3
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 {


+ 1
- 1
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 })


+ 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 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 {


+ 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 帶入)。
* 「審核時間」欄位:`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


+ 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.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
}

+ 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> {
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>


+ 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.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
}
}

+ 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 ?: "")
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
}



+ 20
- 12
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<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(


+ 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 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,


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




読み込み中…
キャンセル
保存