vluk@2fi-solutions.com.hk пре 7 часа
родитељ
комит
aa19f9b29b
20 измењених фајлова са 948 додато и 354 уклоњено
  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. +7
    -3
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
  6. +2
    -2
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt
  7. +2
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt
  8. +13
    -11
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  9. +4
    -4
      src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt
  10. +1
    -4
      src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt
  11. +11
    -1
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLine.kt
  12. +1
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt
  13. +576
    -219
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  14. +29
    -18
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt
  15. +1
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt
  16. +20
    -12
      src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt
  17. +22
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt
  18. +8
    -0
      src/main/resources/db/changelog/changes/20260405_01_Enson/01_alter_stock_take.sql
  19. +9
    -0
      src/main/resources/db/changelog/changes/20260408_01_Enson/02_alter_stock_take.sql
  20. +15
    -0
      src/main/resources/db/changelog/changes/20260408_01_Enson/03_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 {


+ 7
- 3
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt Прегледај датотеку

@@ -1770,7 +1770,7 @@ private fun normalizeFloor(raw: String): String {
val num = cleaned.replace(Regex("[^0-9]"), "")
return if (num.isNotEmpty()) "${num}F" else cleaned
}
open fun getAllJoPickOrders(isDrink: Boolean?, floor: String?): List<AllJoPickOrderResponse> {
open fun getAllJoPickOrders(bomType: String?, floor: String?): List<AllJoPickOrderResponse> {
println("=== getAllJoPickOrders ===")
return try {
@@ -1879,8 +1879,12 @@ open fun getAllJoPickOrders(isDrink: Boolean?, floor: String?): List<AllJoPickOr
val bom = jobOrder.bom

// 按 isDrink 过滤:null 表示不过滤(全部)
if (isDrink != null && bom?.isDrink != isDrink) return@mapNotNull null
// 按 bom.type 过滤:null / blank 表示不过滤(全部)
val normalizedType = bomType?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
if (normalizedType != null) {
val currentType = bom?.type?.trim()?.lowercase()
if (currentType != normalizedType) return@mapNotNull null
}
println("BOM found: ${bom?.id}")
val item = bom?.item


+ 2
- 2
src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt Прегледај датотеку

@@ -243,12 +243,12 @@ fun recordSecondScanIssue(
}
@GetMapping("/AllJoPickOrder")
fun getAllJoPickOrder(
@RequestParam(required = false) isDrink: Boolean?,
@RequestParam(name = "type", required = false) bomType: String?,
// Single floor, e.g. "2F"/"3F"/"4F". When provided, backend returns job pick orders
// that still have unpicked lines on that floor OR any "no lot" remaining lines.
@RequestParam(required = false) floor: String?
): List<AllJoPickOrderResponse> {
return joPickOrderService.getAllJoPickOrders(isDrink, floor)
return joPickOrderService.getAllJoPickOrders(bomType, floor)
}

@GetMapping("/all-lots-hierarchical-by-pick-order/{pickOrderId}")


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt Прегледај датотеку

@@ -28,6 +28,8 @@ open class Bom : BaseEntity<Long>() {
@Column
open var allergicSubstances: Int? = null

@Column
open var type: String? = null
@Column
open var timeSequence: Int? = null
@Column


+ 13
- 11
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt Прегледај датотеку

@@ -1407,11 +1407,15 @@ open class ProductProcessService(
)
}

open fun getAllJoborderProductProcessInfo(isDrink: Boolean?): List<AllJoborderProductProcessInfoResponse> {
open fun getAllJoborderProductProcessInfo(bomType: String?): List<AllJoborderProductProcessInfoResponse> {
val productProcesses = productProcessRepository.findAllByDeletedIsFalse()
val productProcessIds = productProcesses.map { it.id }
val normalizedType = bomType?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
val filteredProductProcesses = productProcesses.filter { process ->
if (normalizedType == null) return@filter true
process.bom?.type?.trim()?.lowercase() == normalizedType
}

return productProcesses.map { productProcesses ->
return filteredProductProcesses.map { productProcesses ->
val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcesses.id ?: 0L)
val jobOrder = jobOrderRepository.findById(productProcesses.jobOrder?.id ?: 0L).orElse(null)
val FinishedProductProcessLineCount =
@@ -1478,9 +1482,6 @@ open class ProductProcessService(
)
}.filter { response ->
// 过滤掉已完成上架的 job order
if (isDrink != null && response.isDrink != isDrink) {
return@filter false
}
val jobOrder = jobOrderRepository.findById(response.jobOrderId ?: 0L).orElse(null)
val stockInLineStatus = jobOrder?.stockInLines?.firstOrNull()?.status
stockInLineStatus != "completed"
@@ -1504,7 +1505,7 @@ open class ProductProcessService(
jobOrderCode: String?,
bomIds: List<Long>?,
qcReady: Boolean?,
isDrink: Boolean?,
bomType: String?,
page: Int,
size: Int
): JobOrderProductProcessPageResponse {
@@ -1514,7 +1515,7 @@ open class ProductProcessService(
val trimmedItemCode = itemCode?.trim()?.takeIf { it.isNotBlank() }
val trimmedJobOrderCode = jobOrderCode?.trim()?.takeIf { it.isNotBlank() }

// 1) 找出候选 jobOrder:用 date/itemCode/jobOrderCode/bomIds/isDrink 先把数据缩小到今天等范围
// 1) 找出候选 jobOrder:用 date/itemCode/jobOrderCode/bomIds/type 先把数据缩小到今天等范围
val candidateProcesses = if (date != null) {
productProcessRepository.findAllByDeletedIsFalseAndDate(date)
} else {
@@ -1523,9 +1524,10 @@ open class ProductProcessService(

val filteredCandidateProcesses = candidateProcesses.filter { p ->
if (p.jobOrder?.isHidden == true) return@filter false
if (isDrink != null) {
val bomIsDrink = p.bom?.isDrink
if (bomIsDrink != isDrink) return@filter false
val normalizedType = bomType?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
if (normalizedType != null) {
val currentType = p.bom?.type?.trim()?.lowercase()
if (currentType != normalizedType) return@filter false
}
if (trimmedItemCode != null) {
val code = p.item?.code


+ 4
- 4
src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt Прегледај датотеку

@@ -194,9 +194,9 @@ class ProductProcessController(
@GetMapping("/Demo/Process/all")
fun demoprocessall(
@RequestParam(required = false) isDrink: Boolean?
@RequestParam(name = "type", required = false) bomType: String?
): List<AllJoborderProductProcessInfoResponse> {
return productProcessService.getAllJoborderProductProcessInfo(isDrink)
return productProcessService.getAllJoborderProductProcessInfo(bomType)
}

@GetMapping("/Demo/Process/search")
@@ -206,7 +206,7 @@ class ProductProcessController(
@RequestParam(required = false) jobOrderCode: String?,
@RequestParam(required = false) bomIds: String?,
@RequestParam(required = false) qcReady: Boolean?,
@RequestParam(required = false) isDrink: Boolean?,
@RequestParam(name = "type", required = false) bomType: String?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int
): JobOrderProductProcessPageResponse {
@@ -225,7 +225,7 @@ class ProductProcessController(
jobOrderCode = jobOrderCode,
bomIds = parsedBomIds,
qcReady = qcReady,
isDrink = isDrink,
bomType = bomType,
page = page,
size = size
)


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




+ 9
- 0
src/main/resources/db/changelog/changes/20260408_01_Enson/02_alter_stock_take.sql Прегледај датотеку

@@ -0,0 +1,9 @@
-- liquibase formatted sql
-- changeset Enson:alter_bom_type
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='fpsmsdb' AND table_name='bom' AND column_name='type'


ALTER TABLE `fpsmsdb`.`bom`
Add COLUMN `type` varchar(255);


+ 15
- 0
src/main/resources/db/changelog/changes/20260408_01_Enson/03_alter_stock_take.sql Прегледај датотеку

@@ -0,0 +1,15 @@
-- liquibase formatted sql
-- changeset Enson:update_bom_type_by_code_and_isDrink_20260408

UPDATE `fpsmsdb`.`bom`
SET `type` = CASE
WHEN isDrink = 1 THEN 'Drink'
WHEN code IN (
'PP2255','PP2257','PP2258','PP2259','PP2261','PP2263','PP2264','PP2266',
'PP2270','PP2275','PP2280','PP2281','PP2283',
'PP2291','PP2295','PP2296','PP2297','PP2299','PP2302','PP2303','PP2304',
'PP2305','PP2339','PP2340'
) THEN 'Powder_Mixture'
ELSE 'Other'
END
WHERE `type` IS NULL OR `type` = '';

Loading…
Откажи
Сачувај