| @@ -35,6 +35,8 @@ public abstract class SettingNames { | |||
| /** Backfill M18 AN document code (grn_code) via GET /root/api/read/an (default 1:20 AM daily) */ | |||
| public static final String SCHEDULE_GRN_CODE_SYNC = "SCHEDULE.grn.grnCode.m18"; | |||
| /** Mark expired inventory lot lines as unavailable (default 00:05 daily) */ | |||
| public static final String SCHEDULE_INVENTORY_LOT_EXPIRY = "SCHEDULE.inventoryLot.expiry"; | |||
| public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; | |||
| @@ -12,6 +12,7 @@ import com.ffii.fpsms.m18.model.SyncResult | |||
| import com.ffii.fpsms.modules.common.SettingNames | |||
| import com.ffii.fpsms.modules.master.service.ProductionScheduleService | |||
| import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | |||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | |||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||
| import jakarta.annotation.PostConstruct | |||
| import org.springframework.beans.factory.annotation.Value | |||
| @@ -37,6 +38,7 @@ open class SchedulerService( | |||
| * missing `grn_code`. Example: 4 = from 4 days ago 00:00 to now. | |||
| */ | |||
| @Value("\${scheduler.grnCodeSync.syncOffsetDays:0}") val grnCodeSyncSyncOffsetDays: Int, | |||
| @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, | |||
| val settingsService: SettingsService, | |||
| val taskScheduler: TaskScheduler, | |||
| val productionScheduleService: ProductionScheduleService, | |||
| @@ -46,6 +48,7 @@ open class SchedulerService( | |||
| val schedulerSyncLogRepository: SchedulerSyncLogRepository, | |||
| val searchCompletedDnService: SearchCompletedDnService, | |||
| val m18GrnCodeSyncService: M18GrnCodeSyncService, | |||
| val inventoryLotLineService: InventoryLotLineService, | |||
| ) { | |||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | |||
| val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | |||
| @@ -64,6 +67,7 @@ open class SchedulerService( | |||
| var scheduledPostCompletedDnGrn: ScheduledFuture<*>? = null | |||
| var scheduledGrnCodeSync: ScheduledFuture<*>? = null | |||
| var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | |||
| //@Volatile | |||
| //var scheduledRoughProd: ScheduledFuture<*>? = null | |||
| @@ -110,6 +114,7 @@ open class SchedulerService( | |||
| scheduleM18MasterData(); | |||
| schedulePostCompletedDnGrn(); | |||
| scheduleGrnCodeSync(); | |||
| scheduleInventoryLotExpiry(); | |||
| //scheduleRoughProd(); | |||
| //scheduleDetailedProd(); | |||
| } | |||
| @@ -193,6 +198,31 @@ open class SchedulerService( | |||
| ) | |||
| } | |||
| /** Mark expired inventory lot lines as unavailable daily. Set scheduler.inventoryLotExpiry.enabled=false to disable. */ | |||
| fun scheduleInventoryLotExpiry() { | |||
| if (!inventoryLotExpiryEnabled) { | |||
| scheduledInventoryLotExpiry?.cancel(false) | |||
| scheduledInventoryLotExpiry = null | |||
| logger.info("Inventory lot expiry scheduler disabled (scheduler.inventoryLotExpiry.enabled=false)") | |||
| return | |||
| } | |||
| scheduledInventoryLotExpiry = commonSchedule( | |||
| scheduledInventoryLotExpiry, | |||
| SettingNames.SCHEDULE_INVENTORY_LOT_EXPIRY, | |||
| "0 5 0 * * *", | |||
| { markExpiredInventoryLotLinesAsUnavailable() } | |||
| ) | |||
| } | |||
| open fun markExpiredInventoryLotLinesAsUnavailable() { | |||
| try { | |||
| val updatedRows = inventoryLotLineService.markExpiredLotLinesAsUnavailable() | |||
| logger.info("Scheduler - Inventory lot expiry update done, rows updated=$updatedRows") | |||
| } catch (e: Exception) { | |||
| logger.error("Scheduler - Inventory lot expiry update failed: ${e.message}", e) | |||
| } | |||
| } | |||
| // Function for schedule | |||
| // --------------------------- FP-MTMS --------------------------- // | |||
| @@ -61,6 +61,12 @@ class SchedulerController( | |||
| return "M18 GRN code sync (read/an) triggered" | |||
| } | |||
| @GetMapping("/trigger/inventory-lot-expiry") | |||
| fun triggerInventoryLotExpiry(): String { | |||
| schedulerService.markExpiredInventoryLotLinesAsUnavailable() | |||
| return "Inventory lot expiry status update triggered" | |||
| } | |||
| @GetMapping("/trigger/post-completed-dn-grn") | |||
| fun triggerPostCompletedDnGrn( | |||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, | |||
| @@ -96,6 +96,8 @@ class DoPickOrderRecord { | |||
| var handlerName: String? = null | |||
| @Column(name = "release_type", length = 100) | |||
| var releaseType: String? = null | |||
| @Column(name = "cartonQty") | |||
| var cartonQty: Int? = null | |||
| // Default constructor for Hibernate | |||
| constructor() | |||
| @@ -1308,6 +1308,7 @@ open class DeliveryOrderService( | |||
| val driver = A4PrintDriverRegistry.getDriver(printer.brand) | |||
| driver.print(tempPdfFile, ip, port, printQty) | |||
| } | |||
| updateRecordCartonQty(request.doPickOrderId, request.numOfCarton) | |||
| } finally { | |||
| //tempPdfFile.delete() | |||
| } | |||
| @@ -1406,6 +1407,7 @@ open class DeliveryOrderService( | |||
| ) | |||
| } | |||
| } | |||
| updateRecordCartonQty(request.doPickOrderId, request.numOfCarton) | |||
| println("Test PDF saved to: ${tempPdfFile.absolutePath}") | |||
| @@ -1415,6 +1417,13 @@ open class DeliveryOrderService( | |||
| } | |||
| private fun updateRecordCartonQty(doPickOrderRecordId: Long, cartonQty: Int) { | |||
| if (cartonQty <= 0) return | |||
| val record = doPickOrderRecordRepository.findById(doPickOrderRecordId).orElse(null) ?: return | |||
| record.cartonQty = cartonQty | |||
| doPickOrderRecordRepository.save(record) | |||
| } | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| open fun releaseDeliveryOrderWithoutTicket(request: ReleaseDoRequest): ReleaseDoResult { | |||
| println(" DEBUG: Starting releaseDeliveryOrderWithoutTicket for DO ID: ${request.id}") | |||
| @@ -5,8 +5,10 @@ import com.ffii.fpsms.modules.stock.entity.projection.CurrentInventoryItemInfo | |||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryLotLineInfo | |||
| import org.springframework.data.domain.Page | |||
| import org.springframework.data.domain.Pageable | |||
| import org.springframework.data.jpa.repository.Modifying | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.stereotype.Repository | |||
| import org.springframework.transaction.annotation.Transactional | |||
| import java.io.Serializable | |||
| import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus | |||
| import org.springframework.data.repository.query.Param | |||
| @@ -145,4 +147,21 @@ interface InventoryLotLineRepository : AbstractRepository<InventoryLotLine, Long | |||
| fun findAllByStoreIdsAndHasStockInLine( | |||
| @Param("storeIds") storeIds: List<String> | |||
| ): List<InventoryLotLine> | |||
| @Modifying | |||
| @Transactional | |||
| @Query( | |||
| value = """ | |||
| UPDATE inventory_lot_line ill | |||
| INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId | |||
| SET ill.status = 'unavailable' | |||
| WHERE il.deleted = 0 | |||
| AND ill.deleted = 0 | |||
| AND il.expiryDate IS NOT NULL | |||
| AND il.expiryDate < CURRENT_DATE | |||
| AND (ill.status IS NULL OR LOWER(ill.status) <> 'unavailable') | |||
| """, | |||
| nativeQuery = true | |||
| ) | |||
| fun markExpiredLotLinesAsUnavailable(): Int | |||
| } | |||
| @@ -60,6 +60,11 @@ open class InventoryLotLineService( | |||
| @Lazy | |||
| private val jobOrderService: JobOrderService | |||
| ) { | |||
| @Transactional | |||
| open fun markExpiredLotLinesAsUnavailable(): Int { | |||
| return inventoryLotLineRepository.markExpiredLotLinesAsUnavailable() | |||
| } | |||
| open fun findById(id: Long): Optional<InventoryLotLine> { | |||
| return inventoryLotLineRepository.findById(id) | |||
| } | |||
| @@ -1162,7 +1162,7 @@ open class StockInLineService( | |||
| this.type = stockInLine.type | |||
| this.itemId = item.id | |||
| this.itemCode = item.code | |||
| this.uomId = inventory.uom?.id ?: itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id | |||
| this.uomId = itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id ?: inventory.uom?.id | |||
| this.date = LocalDate.now() | |||
| } | |||
| @@ -656,31 +656,22 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||
| val statusLower = request.status.trim().lowercase() | |||
| val isPickEnd = statusLower == "completed" || statusLower == "partially_completed" | |||
| val deltaQty = request.qty | |||
| val skipLedgerWrite = request.skipLedgerWrite == true | |||
| // non jo/do: 手工拣货增量扣减时,補寫出库 ledger(幂等 + 跳过 jo/do,避免 double write) | |||
| if (isPickEnd && deltaQty != null && deltaQty > 0 && savedStockOutLine.id != null) { | |||
| // 手工拣货增量扣减时补写出库 ledger(DO/JO 也需要写) | |||
| // 批量提交流程会显式传 skipLedgerWrite=true,避免重复写入。 | |||
| if (!skipLedgerWrite && isPickEnd && deltaQty != null && deltaQty > 0 && savedStockOutLine.id != null) { | |||
| val itemId = savedStockOutLine.item?.id | |||
| val po = savedStockOutLine.pickOrderLine?.pickOrder | |||
| if (itemId != null && po != null) { | |||
| val isJoOrDo = (po.jobOrder != null || po.deliveryOrder != null) | |||
| if (!isJoOrDo) { | |||
| val exists = jdbcDao.queryForInt( | |||
| "SELECT COUNT(*) FROM stock_ledger WHERE deleted = false AND stockOutLineId = :id", | |||
| mapOf("id" to savedStockOutLine.id) | |||
| ) > 0 | |||
| if (!exists) { | |||
| val invBefore = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val onHandBefore = invBefore?.onHandQty?.toDouble() ?: 0.0 | |||
| createStockLedgerForPickDelta( | |||
| stockOutLineId = savedStockOutLine.id!!, | |||
| deltaQty = BigDecimal(deltaQty.toString()), | |||
| onHandQtyBeforeUpdate = onHandBefore | |||
| ) | |||
| } | |||
| } | |||
| if (itemId != null) { | |||
| val invBefore = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val onHandBefore = invBefore?.onHandQty?.toDouble() ?: 0.0 | |||
| createStockLedgerForPickDelta( | |||
| stockOutLineId = savedStockOutLine.id!!, | |||
| deltaQty = BigDecimal(deltaQty.toString()), | |||
| onHandQtyBeforeUpdate = onHandBefore | |||
| ) | |||
| } | |||
| } | |||
| println("Updated StockOutLine: ${savedStockOutLine.id} with status: ${savedStockOutLine.status}") | |||
| @@ -1295,7 +1286,8 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| UpdateStockOutLineStatusRequest( | |||
| id = line.stockOutLineId, | |||
| status = newStatus, // 例如前端传来的 "completed" | |||
| qty = 0.0 // 不改变现有 qty | |||
| qty = 0.0, // 不改变现有 qty | |||
| skipLedgerWrite = true | |||
| ) | |||
| ) | |||
| @@ -1308,7 +1300,8 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| UpdateStockOutLineStatusRequest( | |||
| id = line.stockOutLineId, | |||
| status = newStatus, | |||
| qty = submitQty.toDouble() | |||
| qty = submitQty.toDouble(), | |||
| skipLedgerWrite = true | |||
| ) | |||
| ) | |||
| @@ -1509,8 +1502,8 @@ affectedConsoCodes.forEach { consoCode -> | |||
| this.type = stockOutLine.type | |||
| this.itemId = item.id | |||
| this.itemCode = item.code | |||
| this.uomId = inventory.uom?.id | |||
| ?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | |||
| this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | |||
| ?: inventory.uom?.id | |||
| this.date = LocalDate.now() | |||
| } | |||
| @@ -1607,8 +1600,8 @@ affectedConsoCodes.forEach { consoCode -> | |||
| this.type = "NOR" | |||
| this.itemId = item.id | |||
| this.itemCode = item.code | |||
| this.uomId = inventory.uom?.id | |||
| ?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | |||
| this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | |||
| ?: inventory.uom?.id | |||
| this.date = LocalDate.now() | |||
| } | |||
| @@ -40,6 +40,7 @@ import java.sql.ResultSet | |||
| import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryRepository | |||
| import com.ffii.fpsms.modules.stock.entity.StockLedger | |||
| import com.ffii.fpsms.modules.master.entity.UomConversionRepository | |||
| import java.time.LocalDate | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | |||
| import java.math.RoundingMode | |||
| @@ -59,7 +60,8 @@ class StockTakeRecordService( | |||
| val stockInLineRepository: StockInLineRepository, | |||
| val inventoryLotRepository: InventoryLotRepository, | |||
| val stockLedgerRepository: StockLedgerRepository, | |||
| val inventoryRepository: InventoryRepository | |||
| val inventoryRepository: InventoryRepository, | |||
| val uomConversionRepository: UomConversionRepository | |||
| ) { | |||
| private val logger: Logger = LoggerFactory.getLogger(StockTakeRecordService::class.java) | |||
| @@ -1706,6 +1708,10 @@ fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes< | |||
| itemId = ledger.itemId ?: 0L, | |||
| itemCode = ledger.itemCode, | |||
| itemName = ledger.inventory?.item?.name, | |||
| uomId = ledger.uomId, | |||
| uomDesc = ledger.uomId?.let { uomId -> | |||
| uomConversionRepository.findByIdAndDeletedFalse(uomId)?.udfudesc | |||
| }, | |||
| balanceQty = ledger.balance?.let { balance: Double -> BigDecimal(balance.toString()) }, | |||
| qty = if (ledger.inQty != null && ledger.inQty!! > 0) { | |||
| BigDecimal(ledger.inQty.toString()) | |||
| @@ -59,7 +59,8 @@ data class UpdateStockOutLineStatusRequest( | |||
| val id: Long, | |||
| val status: String, | |||
| val qty: Double? = null, | |||
| val remarks: String? = null | |||
| val remarks: String? = null, | |||
| val skipLedgerWrite: Boolean? = false | |||
| ) | |||
| data class UpdateStockOutLineStatusByQRCodeAndLotNoRequest( | |||
| val pickOrderLineId: Long, | |||
| @@ -127,6 +127,8 @@ data class StockTransactionResponse( | |||
| val itemId: Long, | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| val uomId: Long? = null, | |||
| val uomDesc: String? = null, | |||
| val balanceQty: BigDecimal? = null, | |||
| val qty: BigDecimal, | |||
| val type: String?, | |||
| @@ -27,6 +27,8 @@ scheduler: | |||
| grnCodeSync: | |||
| enabled: true | |||
| syncOffsetDays: 10 # from (today − 10) 00:00 to now, rows missing grn_code | |||
| inventoryLotExpiry: | |||
| enabled: true | |||
| m18: | |||
| config: | |||
| @@ -19,6 +19,8 @@ scheduler: | |||
| enabled: false # set true in prod; backfills grn_code from M18 GET /root/api/read/an | |||
| # Lookback: created from start of (today − N days) through now, missing grn_code. E.g. 4 = last 4 days + today. | |||
| syncOffsetDays: 0 | |||
| inventoryLotExpiry: | |||
| enabled: true | |||
| spring: | |||
| servlet: | |||