Bläddra i källkod

remove bar from round card lsit

lot expiry after create round before appove
tonigh lot will expiry can not do expiry handle ,block while stock taking relate
stockTakeUpdate
CANCERYS\kw093 1 vecka sedan
förälder
incheckning
8b4802eb31
7 ändrade filer med 242 tillägg och 16 borttagningar
  1. +4
    -1
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  2. +65
    -6
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  3. +5
    -1
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt
  4. +24
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  5. +2
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt
  6. +16
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  7. +126
    -7
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt

+ 4
- 1
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt Visa fil

@@ -582,7 +582,10 @@ open class SchedulerService(
open fun markExpiredInventoryLotLinesAsUnavailable() {
try {
val updatedRows = inventoryLotLineService.markExpiredLotLinesAsUnavailable()
logger.info("Scheduler - Inventory lot expiry update done, rows updated=$updatedRows")
logger.info(
"Scheduler - Inventory lot expiry update done, rows updated=$updatedRows " +
"(also soft-deletes open stock-take records for those lots)"
)
} catch (e: Exception) {
logger.error("Scheduler - Inventory lot expiry update failed: ${e.message}", e)
}


+ 65
- 6
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt Visa fil

@@ -52,6 +52,7 @@ import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository
import org.springframework.beans.factory.annotation.Value
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutRequest
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest
import com.ffii.fpsms.modules.stock.entity.StockTakeRecordRepository
@Service
open class PickExecutionIssueService(
private val pickExecutionIssueRepository: PickExecutionIssueRepository,
@@ -72,7 +73,8 @@ open class PickExecutionIssueService(
private val stockLedgerRepository: StockLedgerRepository,
private val deliveryOrderRepository: DeliveryOrderRepository,
private val suggestedPickLotRepository: SuggestPickLotRepository,
private val doPickOrderRepository: DoPickOrderRepository
private val doPickOrderRepository: DoPickOrderRepository,
private val stockTakeRecordRepository: StockTakeRecordRepository,

) {
// 配置属性:是否在批次拒绝时自动重新建议批次(默认为 false,不自动重新建议)
@@ -1833,11 +1835,30 @@ open fun getExpiryItemList(
itemName = normalizedItemName,
lotNo = normalizedLotNo,
)

val openStockTakePairs = openStockTakeLotWarehousePairs(
lotLines.mapNotNull { it.inventoryLot?.id }
)
return lotLines.map { lotLine ->
val lot = lotLine.inventoryLot
val item = lot?.item // Get item from inventoryLot
val expiry = lot?.expiryDate
val dueOrPast = expiry != null && !expiry.isAfter(today)
val expiresToday = expiry != null && expiry.isEqual(today)
val lotId = lot?.id
val warehouseId = lotLine.warehouse?.id
val stockTakeBlocked = expiresToday &&
lotId != null &&
warehouseId != null &&
(lotId to warehouseId) in openStockTakePairs
val canHandle = dueOrPast && !stockTakeBlocked
val blockKey = when {
canHandle -> null
expiry == null || expiry.isAfter(today) -> "FUTURE"
stockTakeBlocked -> "STOCK_TAKE_OPEN"
else -> null
}
ExpiryItemResponse(
id = lotLine.id ?: 0L,
itemId = item?.id ?: 0L,
@@ -1849,11 +1870,36 @@ open fun getExpiryItemList(
expiryDate = expiry,
remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO),
uomDesc = lotLine.stockUom?.uom?.udfudesc,
canHandle = expiry != null && !expiry.isAfter(today),
canHandle = canHandle,
canHandleBlockKey = blockKey,
)
}
}

/** Open-round stock take records keyed by (inventoryLotId, warehouseId). */
private fun openStockTakeLotWarehousePairs(lotIds: Collection<Long>): Set<Pair<Long, Long>> {
val ids = lotIds.filter { it > 0L }.distinct()
if (ids.isEmpty()) return emptySet()
return stockTakeRecordRepository.findAllByLotIdInAndDeletedIsFalse(ids)
.mapNotNull { rec ->
val header = rec.stockTake ?: return@mapNotNull null
if (header.deleted || header.status?.isOpen() != true) return@mapNotNull null
val lotId = rec.lotId ?: return@mapNotNull null
val warehouseId = rec.warehouse?.id ?: return@mapNotNull null
lotId to warehouseId
}
.toSet()
}

/** 今日到期且仍在 open 盤點中 → 不可過期處置(已過期仍可處置)。 */
private fun isExpiresTodayBlockedByOpenStockTake(lotLine: InventoryLotLine, today: LocalDate = LocalDate.now()): Boolean {
val expiry = lotLine.inventoryLot?.expiryDate ?: return false
if (!expiry.isEqual(today)) return false
val lotId = lotLine.inventoryLot?.id ?: return false
val warehouseId = lotLine.warehouse?.id ?: return false
return (lotId to warehouseId) in openStockTakeLotWarehousePairs(listOf(lotId))
}

@Transactional(rollbackFor = [Exception::class])
open fun submitMissItem(request: SubmitIssueRequest): MessageResponse {
try {
@@ -2030,6 +2076,17 @@ open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
errorPosition = null
)
}

if (isExpiresTodayBlockedByOpenStockTake(lotLine, today)) {
return MessageResponse(
id = null,
name = "Error",
code = "STOCK_TAKE_OPEN",
type = "stock_issue",
message = "Expires today and is in an open stock take; cannot dispose until stock take finishes or the lot leaves the round",
errorPosition = null
)
}
val inQty = lotLine.inQty ?: BigDecimal.ZERO
val outQty = lotLine.outQty ?: BigDecimal.ZERO
@@ -2214,19 +2271,21 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse {
@Transactional(rollbackFor = [Exception::class])
open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse {
try {
val today = LocalDate.now()
val lotLines = inventoryLotLineRepository.findAllById(request.lotLineIds)
.filter {
val lot = it.inventoryLot
val today = LocalDate.now()
lot?.expiryDate != null &&
lot.expiryDate!!.let { !it.isAfter(today) } &&
(it.inQty ?: BigDecimal.ZERO) > (it.outQty ?: BigDecimal.ZERO)
lot.expiryDate!!.let { d -> !d.isAfter(today) } &&
(it.inQty ?: BigDecimal.ZERO) > (it.outQty ?: BigDecimal.ZERO) &&
!isExpiresTodayBlockedByOpenStockTake(it, today)
}

if (lotLines.isEmpty()) {
return MessageResponse(
id = null, name = "Error", code = "EMPTY", type = "stock_issue",
message = "No valid expiry items to submit", errorPosition = null
message = "No valid expiry items to submit (today-expiry lots in open stock take are blocked)",
errorPosition = null
)
}



+ 5
- 1
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt Visa fil

@@ -32,8 +32,12 @@ data class ExpiryItemResponse(
val expiryDate: LocalDate?,
val remainingQty: BigDecimal,
val uomDesc: String?,
/** True when expiryDate is today or earlier. */
/** True when expiryDate is today or earlier, and not blocked by open stock take (today-expiry). */
val canHandle: Boolean,
/**
* When [canHandle] is false: `FUTURE` (not yet due) or `STOCK_TAKE_OPEN` (expires today but in open stock take).
*/
val canHandleBlockKey: String? = null,
)
data class LotIssueDetailRequest(
val lotId: Long,


+ 24
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt Visa fil

@@ -287,6 +287,30 @@ WHERE ill.id = :id
@Param("storeIds") storeIds: List<String>
): List<InventoryLotLine>

/**
* Lot lines that the daily expiry job will mark unavailable
* ([InventoryLot.expiryDate] &lt; today and status not yet unavailable).
*/
@EntityGraph(
type = EntityGraph.EntityGraphType.FETCH,
attributePaths = ["inventoryLot", "warehouse"]
)
@Query(
"""
SELECT ill FROM InventoryLotLine ill
JOIN ill.inventoryLot il
WHERE ill.deleted = false
AND il.deleted = false
AND il.expiryDate IS NOT NULL
AND il.expiryDate < :today
AND (ill.status IS NULL OR ill.status <> :unavailable)
"""
)
fun findExpiredLotLinesNotYetUnavailable(
@Param("today") today: LocalDate,
@Param("unavailable") unavailable: InventoryLotLineStatus,
): List<InventoryLotLine>

@Modifying
@Transactional
@Query(


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt Visa fil

@@ -19,6 +19,8 @@ interface StockTakeRecordRepository : AbstractRepository<StockTakeRecord, Long>
warehouseIds: Collection<Long>
): List<StockTakeRecord>

fun findAllByLotIdInAndDeletedIsFalse(lotIds: Collection<Long>): List<StockTakeRecord>

@Query(
"""


+ 16
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt Visa fil

@@ -64,10 +64,25 @@ open class InventoryLotLineService(
@Lazy
private val jobOrderService: JobOrderService,
private val stockTakeLocationGuard: StockTakeLocationGuard,
@Lazy
private val stockTakeRecordService: StockTakeRecordService,
) {
/**
* Daily job: mark expired lot lines UNAVAILABLE, then drop matching open stock-take
* placeholders so picker/approver and section status no longer see those lots.
*/
@Transactional
open fun markExpiredLotLinesAsUnavailable(): Int {
return inventoryLotLineRepository.markExpiredLotLinesAsUnavailable()
val today = LocalDate.now()
val candidates = inventoryLotLineRepository.findExpiredLotLinesNotYetUnavailable(
today,
InventoryLotLineStatus.UNAVAILABLE,
)
val updatedRows = inventoryLotLineRepository.markExpiredLotLinesAsUnavailable()
if (candidates.isNotEmpty()) {
stockTakeRecordService.softDeleteOpenStockTakeRecordsForExpiredLotLines(candidates)
}
return updatedRows
}

open fun findById(id: Long): Optional<InventoryLotLine> {


+ 126
- 7
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Visa fil

@@ -496,8 +496,17 @@ open class StockTakeRecordService(
return@forEach
}

// 修復:曾用整輪代表 id 升錯單頭時,本區明細已全 completed 卻仍停在 approving
val status = if (latestStockTake != null) {
latestStockTake.status?.value ?: "pending"
val headerId = latestStockTake.id
if (headerId != null && latestStockTake.status == StockTakeStatus.APPROVING) {
checkAndUpdateStockTakeStatus(headerId, stockTakeSection)
stockTakeRepository.findByIdAndDeletedIsFalse(headerId)?.status?.value
?: latestStockTake.status?.value
?: "pending"
} else {
latestStockTake.status?.value ?: "pending"
}
} else {
"no_cycle"
}
@@ -1186,6 +1195,61 @@ open class StockTakeRecordService(
.sorted()
}

/**
* 排程將過期 lot line 標成 UNAVAILABLE 後呼叫:對仍 open 的盤點輪次,
* soft-delete 對應且尚未 completed 的 [StockTakeRecord]/[StockTakeLine],
* 再重算受影響區域單頭狀態。
*/
@Transactional
open fun softDeleteOpenStockTakeRecordsForExpiredLotLines(lotLines: List<InventoryLotLine>): Int {
if (lotLines.isEmpty()) return 0
val pairs = lotLines.mapNotNull { ill ->
val lotId = ill.inventoryLot?.id ?: return@mapNotNull null
val warehouseId = ill.warehouse?.id ?: return@mapNotNull null
lotId to warehouseId
}.toSet()
if (pairs.isEmpty()) return 0

val lotIds = pairs.map { it.first }.distinct()
val toRemove = stockTakeRecordRepository.findAllByLotIdInAndDeletedIsFalse(lotIds).filter { rec ->
val lotId = rec.lotId ?: return@filter false
val warehouseId = rec.warehouse?.id ?: return@filter false
if ((lotId to warehouseId) !in pairs) return@filter false
if (rec.status.equals("completed", ignoreCase = true)) return@filter false
val header = rec.stockTake ?: return@filter false
!header.deleted && header.status?.isOpen() == true
}
if (toRemove.isEmpty()) return 0

val recordIds = toRemove.mapNotNull { it.id }
toRemove.forEach { it.deleted = true }
stockTakeRecordRepository.saveAll(toRemove)

if (recordIds.isNotEmpty()) {
val lines = stockTakeLineRepository.findAllByStockTakeRecord_IdInAndDeletedIsFalse(recordIds)
lines.forEach { it.deleted = true }
if (lines.isNotEmpty()) {
stockTakeLineRepository.saveAll(lines)
}
}

val headersToRefresh = toRemove.mapNotNull { rec ->
val stId = rec.stockTake?.id ?: return@mapNotNull null
val section = rec.stockTakeSection?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
stId to section
}.distinct()
headersToRefresh.forEach { (stId, section) ->
checkAndUpdateStockTakeStatus(stId, section)
}

logger.info(
"softDeleteOpenStockTakeRecordsForExpiredLotLines: removed {} record(s), refreshed {} section header(s)",
toRemove.size,
headersToRefresh.size,
)
return toRemove.size
}

open fun listStockTakeRoundCards(yearMonth: String?): StockTakeRoundCardsRes {
val all = stockTakeRepository.findAll().filter { !it.deleted }
if (all.isEmpty()) {
@@ -1843,6 +1907,13 @@ open class StockTakeRecordService(
this.stockTakerName = user?.name
this.stockTakeEndTime = java.time.LocalDateTime.now()
this.varianceQty = varianceQty
// 防舊資料:重新盤點後不得沿用先前核准結果
this.approverId = null
this.approverName = null
this.approverStockTakeQty = null
this.approverBadQty = null
this.approverTime = null
this.lastSelect = null
}
existingRecord
}
@@ -2098,10 +2169,14 @@ open class StockTakeRecordService(

/**
* FP-MTMS Version Checklist | Functions Ref. No. 70 | v1.0.0 | 2026-08-31
*
* 依 [stockTakeSection] 在同一輪次內解析「該區域」單頭再升狀態。
* 不可直接信任傳入的 stockTakeId:Overall 審核會傳整輪代表 id,若寫錯單頭會造成
* 本區已全數 completed 卻仍停在 approving(狀態寫到別區)。
*/
open fun checkAndUpdateStockTakeStatus(stockTakeId: Long, stockTakeSection: String): Map<String, Any> {
try {
val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId)
val stockTakeHint = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId)
?: return mapOf("success" to false, "message" to "Stock take not found")

// 1. 获取该 section 下的所有 warehouse
@@ -2112,7 +2187,21 @@ open class StockTakeRecordService(

val warehouseIds = warehouses.mapNotNull { it.id }.toSet()

val roundId = stockTake.stockTakeRoundId ?: stockTake.id
val roundId = stockTakeHint.stockTakeRoundId ?: stockTakeHint.id
?: return mapOf("success" to false, "message" to "Stock take id is null")

val sectionHeaders = stockTakeRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(roundId)
.filter { !it.deleted && it.stockTakeSection == stockTakeSection }
val stockTake = sectionHeaders.maxWithOrNull(
compareBy<StockTake> { it.actualStart ?: it.planStart ?: LocalDateTime.MIN }
.thenBy { it.id ?: 0L }
) ?: stockTakeHint.takeIf { it.stockTakeSection == stockTakeSection }
?: return mapOf(
"success" to false,
"message" to "Stock take header not found for section $stockTakeSection in round $roundId"
)

val sectionHeaderId = stockTake.id
?: return mapOf("success" to false, "message" to "Stock take id is null")

var stockTakeRecords = stockTakeRecordRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(roundId)
@@ -2126,7 +2215,7 @@ open class StockTakeRecordService(
stockTakeRecords = stockTakeRecordRepository.findAll()
.filter {
!it.deleted &&
it.stockTake?.id == stockTakeId &&
it.stockTake?.id == sectionHeaderId &&
it.warehouse?.id in warehouseIds &&
it.stockTakeSection == stockTakeSection
}
@@ -2144,7 +2233,10 @@ open class StockTakeRecordService(
stockTake.planEnd = now
stockTake.actualEnd = now
stockTakeRepository.save(stockTake)
println("Stock take $stockTakeId status updated to COMPLETED - all records are completed")
println(
"Stock take $sectionHeaderId (section=$stockTakeSection, hintId=$stockTakeId) " +
"status updated to COMPLETED - all records are completed"
)
return mapOf(
"success" to true,
"message" to "Stock take status updated to COMPLETED",
@@ -2155,7 +2247,10 @@ open class StockTakeRecordService(
stockTake.actualEnd = now
stockTakeRepository.save(stockTake)

println("Stock take $stockTakeId status updated to APPROVING - all records are pass")
println(
"Stock take $sectionHeaderId (section=$stockTakeSection, hintId=$stockTakeId) " +
"status updated to APPROVING - all records are pass"
)
return mapOf(
"success" to true,
"message" to "Stock take status updated to APPROVING",
@@ -2272,7 +2367,9 @@ open class StockTakeRecordService(
}
val stockTakeSection = savedRecord.stockTakeSection
if (stockTakeSection != null) {
checkAndUpdateStockTakeStatus(stockTakeId, stockTakeSection)
// 用明細所屬區域單頭 id(Overall 審核傳入的可能是整輪代表 id)
val statusHeaderId = savedRecord.stockTake?.id ?: stockTakeId
checkAndUpdateStockTakeStatus(statusHeaderId, stockTakeSection)
}

return savedRecord
@@ -3248,8 +3345,30 @@ open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTa
stockTakeRecord.apply {
this.status = "notMatch"
// 重新盤點後必須再審核;清掉舊核准欄位,避免 UI 仍顯示在 Approved、單頭卻升不了 completed
this.approverId = null
this.approverName = null
this.approverStockTakeQty = null
this.approverBadQty = null
this.approverTime = null
this.lastSelect = null
this.pickerSecondStockTakeQty = null
this.pickerSecondBadQty = null
this.stockTakeEndTime = null
}
val saved = stockTakeRecordRepository.save(stockTakeRecord)

saved.id?.let { rid ->
stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid)?.let { line ->
line.apply {
this.finalQty = null
this.status = StockTakeLineStatus.PENDING
this.completeDate = null
}
stockTakeLineRepository.save(line)
}
}

val stockTake = saved.stockTake
if (stockTake != null && !stockTake.deleted) {
stockTake.status = StockTakeStatus.STOCKTAKING


Laddar…
Avbryt
Spara