Procházet zdrojové kódy

Merge branch 'production' into fix負數倉

fix負數倉
CANCERYS\kw093 před 1 týdnem
rodič
revize
70e590ec4d
31 změnil soubory, kde provedl 939 přidání a 157 odebrání
  1. +4
    -2
      src/main/java/com/ffii/core/support/JdbcDao.java
  2. +4
    -2
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  3. +1
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  4. +2
    -2
      src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt
  5. +13
    -9
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  6. +24
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt
  7. +118
    -65
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
  8. +1
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt
  9. +91
    -34
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  10. +18
    -8
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  11. +35
    -5
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt
  12. +3
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt
  13. +247
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt
  14. +126
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertReportService.kt
  15. +40
    -9
      src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt
  16. +54
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt
  17. +4
    -3
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  18. +2
    -1
      src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt
  19. +8
    -6
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  20. +3
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecord.kt
  21. +14
    -1
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecordRepository.kt
  22. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt
  23. +26
    -6
      src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt
  24. +23
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt
  25. +8
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/StockAdjustmentController.kt
  26. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt
  27. +7
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockAdjustmentRequest.kt
  28. +2
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt
  29. +5
    -0
      src/main/resources/db/changelog/changes/20260907_do1_2300/01_update_do1_schedule_to_23.sql
  30. +6
    -0
      src/main/resources/db/changelog/changes/20260908_stock_adjustment_record_remarks/01_add_remarks.sql
  31. +42
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt

+ 4
- 2
src/main/java/com/ffii/core/support/JdbcDao.java Zobrazit soubor

@@ -46,7 +46,8 @@ public class JdbcDao {
*/ */
public String queryForString(String sql, Map<String, ?> paramMap) { public String queryForString(String sql, Map<String, ?> paramMap) {
try { try {
return this.template.queryForObject(sql, paramMap, String.class);
String value = this.template.queryForObject(sql, paramMap, String.class);
return value != null ? value : StringUtils.EMPTY;
} catch (EmptyResultDataAccessException e) { } catch (EmptyResultDataAccessException e) {
return StringUtils.EMPTY; return StringUtils.EMPTY;
} }
@@ -60,7 +61,8 @@ public class JdbcDao {
*/ */
public String queryForString(String sql, Object paramObj) { public String queryForString(String sql, Object paramObj) {
try { try {
return this.template.queryForObject(sql, new BeanPropertySqlParameterSource(paramObj), String.class);
String value = this.template.queryForObject(sql, new BeanPropertySqlParameterSource(paramObj), String.class);
return value != null ? value : StringUtils.EMPTY;
} catch (EmptyResultDataAccessException e) { } catch (EmptyResultDataAccessException e) {
return StringUtils.EMPTY; return StringUtils.EMPTY;
} }


+ 4
- 2
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java Zobrazit soubor

@@ -77,8 +77,8 @@ public class SecurityConfig {
/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 * FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13
* FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06
* FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05
* (stockAdjustment/submit → INVENTORY_ADJUST only)
* FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08
* (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST)
*/ */
@Bean @Bean
@Order(1) @Order(1)
@@ -119,6 +119,8 @@ public class SecurityConfig {
.hasAnyAuthority("TESTING", "ADMIN") .hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.POST, "/stockAdjustment/submit") .requestMatchers(HttpMethod.POST, "/stockAdjustment/submit")
.hasAuthority("INVENTORY_ADJUST") .hasAuthority("INVENTORY_ADJUST")
.requestMatchers(HttpMethod.GET, "/stockAdjustment/latestRemarks")
.hasAuthority("INVENTORY_ADJUST")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace") .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace")
.hasAuthority("ITEM_TRACING") .hasAuthority("ITEM_TRACING")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**") .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**")


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java Zobrazit soubor

@@ -25,6 +25,7 @@ public abstract class SettingNames {
*/ */
public static final String SCHEDULE_M18_PO = "SCHEDULE.m18.po"; public static final String SCHEDULE_M18_PO = "SCHEDULE.m18.po";


/** Mon–Fri & Sun DO1 time (default 23:00). Saturday uses [SCHEDULE_M18_DO1_SAT]. */
public static final String SCHEDULE_M18_DO1 = "SCHEDULE.m18.do1"; public static final String SCHEDULE_M18_DO1 = "SCHEDULE.m18.do1";
/** Saturday-only DO1 time (default 03:10). Mon–Fri & Sun use [SCHEDULE_M18_DO1] time via a second trigger. */ /** Saturday-only DO1 time (default 03:10). Mon–Fri & Sun use [SCHEDULE_M18_DO1] time via a second trigger. */
public static final String SCHEDULE_M18_DO1_SAT = "SCHEDULE.m18.do1.sat"; public static final String SCHEDULE_M18_DO1_SAT = "SCHEDULE.m18.do1.sat";


+ 2
- 2
src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt Zobrazit soubor

@@ -229,10 +229,10 @@ open class SchedulerSyncAlertService(
val cron = val cron =
if (date.dayOfWeek == DayOfWeek.SATURDAY) { if (date.dayOfWeek == DayOfWeek.SATURDAY) {
settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_SAT).getOrNull()?.value settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_SAT).getOrNull()?.value
?: "0 10 3 ? * SAT"
?: SchedulerService.DO1_SAT_DEFAULT_CRON
} else { } else {
settingsService.findByName(SettingNames.SCHEDULE_M18_DO1).getOrNull()?.value settingsService.findByName(SettingNames.SCHEDULE_M18_DO1).getOrNull()?.value
?: "0 10 19 * * *"
?: SchedulerService.DO1_DEFAULT_CRON
} }
return scheduledTimeToday(cron, date) return scheduledTimeToday(cron, date)
} }


+ 13
- 9
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt Zobrazit soubor

@@ -79,6 +79,11 @@ open class SchedulerService(
@Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean, @Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean,
) { ) {
companion object { companion object {
/** Mon–Fri & Sun DO1 (23:00). Saturday stays on [DO1_SAT_DEFAULT_CRON]. */
const val DO1_DEFAULT_CRON: String = "0 0 23 * * *"
const val DO1_SAT_DEFAULT_CRON: String = "0 10 3 ? * SAT"
/** DO2 lastModified from yesterday this hour (aligns with weekday/Sun DO1). Sunday uses Sat 03:00. */
const val DO2_MODIFIED_FROM_HOUR: Int = 23
/** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */ /** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */
const val DO2_MODIFIED_TO_HOUR: Int = 13 const val DO2_MODIFIED_TO_HOUR: Int = 13
const val DO2_DEFAULT_CRON: String = "0 0 13 * * *" const val DO2_DEFAULT_CRON: String = "0 0 13 * * *"
@@ -186,18 +191,18 @@ open class SchedulerService(
return return
} }


var cronMain = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1).getOrNull()?.value ?: "0 10 19 * * *"
var cronMain = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1).getOrNull()?.value ?: DO1_DEFAULT_CRON
if (!isValidCronExpression(cronMain)) { if (!isValidCronExpression(cronMain)) {
cronMain = "0 10 19 * * *"
cronMain = DO1_DEFAULT_CRON
} }
val weekdaySunCron = cronToMonFriSunSchedule(cronMain) val weekdaySunCron = cronToMonFriSunSchedule(cronMain)
val mainCron = if (isValidCronExpression(weekdaySunCron)) weekdaySunCron else cronMain val mainCron = if (isValidCronExpression(weekdaySunCron)) weekdaySunCron else cronMain


scheduledM18Do1 = taskScheduler.schedule({ getM18Dos1() }, CronTrigger(mainCron)) scheduledM18Do1 = taskScheduler.schedule({ getM18Dos1() }, CronTrigger(mainCron))


var cronSat = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_SAT).getOrNull()?.value ?: "0 10 3 ? * SAT"
var cronSat = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_SAT).getOrNull()?.value ?: DO1_SAT_DEFAULT_CRON
if (!isValidCronExpression(cronSat)) { if (!isValidCronExpression(cronSat)) {
cronSat = "0 10 3 ? * SAT"
cronSat = DO1_SAT_DEFAULT_CRON
} }
scheduledM18Do1Sat = taskScheduler.schedule({ getM18Dos1() }, CronTrigger(cronSat)) scheduledM18Do1Sat = taskScheduler.schedule({ getM18Dos1() }, CronTrigger(cronSat))


@@ -824,14 +829,13 @@ open class SchedulerService(
val ysd = today.minusDays(1L) val ysd = today.minusDays(1L)
val tmr = today.plusDays(1L) val tmr = today.plusDays(1L)


// Default: lastModified from yesterday 19:00 through today's DO2 run hour (1pm; aligns with SCHEDULE.m18.do2).
// On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1
// (otherwise Sat 03:00–18:59 would be skipped until a much later sync).
// Default: lastModified from yesterday 23:00 through today's DO2 run hour (1pm; aligns with weekday/Sun DO1).
// On Sunday, yesterday is Saturday: keep 03:00 so we include DO changed after Sat 03:10 DO1.
val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY
val modifiedFromStart = if (isSundayDo2) { val modifiedFromStart = if (isSundayDo2) {
ysd.withHour(3).withMinute(0).withSecond(0) ysd.withHour(3).withMinute(0).withSecond(0)
} else { } else {
ysd.withHour(19).withMinute(0).withSecond(0)
ysd.withHour(DO2_MODIFIED_FROM_HOUR).withMinute(0).withSecond(0)
} }


val modifiedDateToEnd = val modifiedDateToEnd =
@@ -840,7 +844,7 @@ open class SchedulerService(
logger.info( logger.info(
"DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}",
modifiedFromStart.format(dateTimeStringFormat), modifiedFromStart.format(dateTimeStringFormat),
if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00",
if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 23:00",
modifiedDateToEnd.format(dateTimeStringFormat), modifiedDateToEnd.format(dateTimeStringFormat),
) )




+ 24
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt Zobrazit soubor

@@ -28,6 +28,8 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.AssignByDoPickOrderIdRequ
import com.ffii.fpsms.modules.user.entity.UserRepository import com.ffii.fpsms.modules.user.entity.UserRepository
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository
import org.springframework.security.core.context.SecurityContext
import org.springframework.security.core.context.SecurityContextHolder
import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus
import jakarta.persistence.OptimisticLockException import jakarta.persistence.OptimisticLockException
import org.hibernate.StaleObjectStateException import org.hibernate.StaleObjectStateException
@@ -491,6 +493,7 @@ class DoReleaseCoordinatorService(
return ids return ids
} }
} }
/** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */
fun startBatchReleaseAsync(ids: List<Long>, userId: Long): MessageResponse { fun startBatchReleaseAsync(ids: List<Long>, userId: Long): MessageResponse {
if (ids.isEmpty()) { if (ids.isEmpty()) {
return MessageResponse( return MessageResponse(
@@ -502,10 +505,13 @@ class DoReleaseCoordinatorService(
val jobId = UUID.randomUUID().toString() val jobId = UUID.randomUUID().toString()
val status = BatchReleaseJobStatus(jobId = jobId, total = ids.size) val status = BatchReleaseJobStatus(jobId = jobId, total = ids.size)
jobs[jobId] = status jobs[jobId] = status
val workerSecurityContext = copySecurityContextForWorker()


executor.submit { executor.submit {
batchReleaseConcurrencyGate.acquireUninterruptibly()
try { try {
SecurityContextHolder.setContext(workerSecurityContext)
batchReleaseConcurrencyGate.acquireUninterruptibly()
try {
try { try {
println("Starting batch release for ${ids.size} orders (job $jobId)") println("Starting batch release for ${ids.size} orders (job $jobId)")
val sortedIds = getOrderedDeliveryOrderIds(ids) val sortedIds = getOrderedDeliveryOrderIds(ids)
@@ -643,6 +649,9 @@ class DoReleaseCoordinatorService(
} finally { } finally {
batchReleaseConcurrencyGate.release() batchReleaseConcurrencyGate.release()
} }
} finally {
SecurityContextHolder.clearContext()
}
} }


return MessageResponse( return MessageResponse(
@@ -851,6 +860,7 @@ class DoReleaseCoordinatorService(
) )
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */
fun startBatchReleaseAsyncSingle(doId: Long, userId: Long): MessageResponse { fun startBatchReleaseAsyncSingle(doId: Long, userId: Long): MessageResponse {
val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId) val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId)
?: return MessageResponse( ?: return MessageResponse(
@@ -858,8 +868,11 @@ class DoReleaseCoordinatorService(
message = "Delivery Order not found", errorPosition = null, entity = null message = "Delivery Order not found", errorPosition = null, entity = null
) )
val workerSecurityContext = copySecurityContextForWorker()

executor.submit { executor.submit {
try { try {
SecurityContextHolder.setContext(workerSecurityContext)
println("📦 Starting single release for DO $doId") println("📦 Starting single release for DO $doId")
// 调用 releaseDeliveryOrderWithoutTicket 创建 pick order // 调用 releaseDeliveryOrderWithoutTicket 创建 pick order
@@ -958,6 +971,8 @@ class DoReleaseCoordinatorService(
} catch (e: Exception) { } catch (e: Exception) {
println("❌ Single release exception: ${e.message}") println("❌ Single release exception: ${e.message}")
e.printStackTrace() e.printStackTrace()
} finally {
SecurityContextHolder.clearContext()
} }
} }
@@ -968,6 +983,14 @@ class DoReleaseCoordinatorService(
) )
} }
/** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */
private fun copySecurityContextForWorker(): SecurityContext {
val parent = SecurityContextHolder.getContext()
val copy = SecurityContextHolder.createEmptyContext()
copy.authentication = parent.authentication
return copy
}

private fun updateSingleTicketNumbers() { private fun updateSingleTicketNumbers() {
try { try {
// 1. 查找所有 TEMP- 开头的 single release type 订单 // 1. 查找所有 TEMP- 开头的 single release type 订单


+ 118
- 65
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt Zobrazit soubor

@@ -9,6 +9,9 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchMergeTicketCandi
import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService
import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService
import com.ffii.fpsms.modules.user.entity.UserRepository
import org.springframework.security.core.context.SecurityContext
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.Instant import java.time.Instant
@@ -95,6 +98,7 @@ open class DoWorkbenchReleaseService(
private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService,
private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, private val stockOutLineWorkbenchService: StockOutLineWorkbenchService,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
private val userRepository: UserRepository,
) { ) {
private val poolSize = Runtime.getRuntime().availableProcessors() private val poolSize = Runtime.getRuntime().availableProcessors()
private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4)) private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4))
@@ -142,6 +146,7 @@ open class DoWorkbenchReleaseService(
open fun startBatchReleaseAsyncSingleV2(ids: List<Long>, userId: Long): MessageResponse = open fun startBatchReleaseAsyncSingleV2(ids: List<Long>, userId: Long): MessageResponse =
startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "single") startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "single")


/** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */
private fun startBatchReleaseAsyncInternal( private fun startBatchReleaseAsyncInternal(
ids: List<Long>, ids: List<Long>,
userId: Long, userId: Long,
@@ -164,75 +169,83 @@ open class DoWorkbenchReleaseService(
val jobId = UUID.randomUUID().toString() val jobId = UUID.randomUUID().toString()
val status = WorkbenchBatchReleaseJobStatus(jobId = jobId, total = ids.size) val status = WorkbenchBatchReleaseJobStatus(jobId = jobId, total = ids.size)
jobs[jobId] = status jobs[jobId] = status
val actor = resolveReleaseActor(userId)
// Capture auth before worker thread: BaseEntity @PrePersist needs SecurityContext for pick_order / lines.
val workerSecurityContext = copySecurityContextForWorker()


executor.submit { executor.submit {
batchReleaseConcurrencyGate.acquireUninterruptibly()
try { try {
val orderedIds = getOrderedDeliveryOrderIds(ids)
val successResults = mutableListOf<ReleaseDoResult>()
SecurityContextHolder.setContext(workerSecurityContext)
batchReleaseConcurrencyGate.acquireUninterruptibly()
try {
val orderedIds = getOrderedDeliveryOrderIds(ids)
val successResults = mutableListOf<ReleaseDoResult>()


orderedIds.forEach { deliveryOrderId ->
try {
val statusRows = jdbcDao.queryForList(
"""
SELECT status
FROM fpsmsdb.delivery_order
WHERE id = :id AND deleted = 0
""".trimIndent(),
mapOf("id" to deliveryOrderId)
)
val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase()
if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) {
return@forEach
}
orderedIds.forEach { deliveryOrderId ->
try {
val statusRows = jdbcDao.queryForList(
"""
SELECT status
FROM fpsmsdb.delivery_order
WHERE id = :id AND deleted = 0
""".trimIndent(),
mapOf("id" to deliveryOrderId)
)
val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase()
if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) {
return@forEach
}


val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2)
val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2)


if (released != null) {
successResults += released
status.success.incrementAndGet()
} else {
if (released != null) {
successResults += released
status.success.incrementAndGet()
} else {
synchronized(status.failed) {
status.failed.add(deliveryOrderId to "Release returned null")
}
}
} catch (e: Exception) {
synchronized(status.failed) { synchronized(status.failed) {
status.failed.add(deliveryOrderId to "Release returned null")
status.failed.add(deliveryOrderId to (e.message ?: "Unknown error"))
} }
} }
} catch (e: Exception) {
synchronized(status.failed) {
status.failed.add(deliveryOrderId to (e.message ?: "Unknown error"))
}
} }
}

try {
createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType, mergeExtraIntoLaneTicket)
} catch (e: Exception) {
// header-link failure shouldn't crash job; status.failed already includes per-DO failures
println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}")
}


val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults)
try {
createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType, mergeExtraIntoLaneTicket, actor)
} catch (e: Exception) {
// header-link failure shouldn't crash job; status.failed already includes per-DO failures
println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}")
}


if (!useV2) {
val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet()
pickOrdersForDownstream.forEach { pickOrderId ->
try {
suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(pickOrderId)
stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId)
} catch (e: Exception) {
val deliveryOrderId = successResults.firstOrNull { it.pickOrderId == pickOrderId }?.deliveryOrderId
?: 0L
synchronized(status.failed) {
status.failed.add(
deliveryOrderId to ("Downstream workbench step failed for pick order $pickOrderId: ${e.message}")
)
val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults)

if (!useV2) {
val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet()
pickOrdersForDownstream.forEach { pickOrderId ->
try {
suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(pickOrderId)
stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId)
} catch (e: Exception) {
val deliveryOrderId = successResults.firstOrNull { it.pickOrderId == pickOrderId }?.deliveryOrderId
?: 0L
synchronized(status.failed) {
status.failed.add(
deliveryOrderId to ("Downstream workbench step failed for pick order $pickOrderId: ${e.message}")
)
}
} }
} }
} }
} finally {
status.running = false
status.finishedAt = Instant.now().toEpochMilli()
batchReleaseConcurrencyGate.release()
} }
} finally { } finally {
status.running = false
status.finishedAt = Instant.now().toEpochMilli()
batchReleaseConcurrencyGate.release()
SecurityContextHolder.clearContext()
} }
} }


@@ -318,6 +331,7 @@ open class DoWorkbenchReleaseService(
) )
} }


val actor = resolveReleaseActor(userId)
val orderedIds = getOrderedDeliveryOrderIds(ids) val orderedIds = getOrderedDeliveryOrderIds(ids)
val successResults = mutableListOf<ReleaseDoResult>() val successResults = mutableListOf<ReleaseDoResult>()
val failed = mutableListOf<Pair<Long, String>>() val failed = mutableListOf<Pair<Long, String>>()
@@ -350,7 +364,7 @@ open class DoWorkbenchReleaseService(
} }
} }


val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch", mergeExtraIntoLaneTicket)
val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch", mergeExtraIntoLaneTicket, actor)
val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults) val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults)
if (!useV2) { if (!useV2) {
val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet() val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet()
@@ -504,6 +518,37 @@ open class DoWorkbenchReleaseService(
} }
} }


/**
* FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08
* Username for ticket createdBy/modifiedBy. Capture on the request thread, then copy onto the worker.
*/
private fun resolveReleaseActor(userId: Long): String {
val fromSecurity = SecurityContextHolder.getContext().authentication?.name
?.trim()
?.takeIf { it.isNotEmpty() && !it.equals("anonymousUser", ignoreCase = true) }
?.take(30)
if (fromSecurity != null) return fromSecurity
if (userId > 0L) {
val fromUser = userRepository.findById(userId).orElse(null)?.username
?.trim()
?.takeIf { it.isNotEmpty() }
?.take(30)
if (fromUser != null) return fromUser
}
return "system"
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08
* Copy auth for the worker thread so BaseEntity @PrePersist matches JO / consumable pick-order creation.
*/
private fun copySecurityContextForWorker(): SecurityContext {
val parent = SecurityContextHolder.getContext()
val copy = SecurityContextHolder.createEmptyContext()
copy.authentication = parent.authentication
return copy
}

private fun resolveTicketFloorSegment(storeId: String?, isDefaultTruckLane: Boolean): String = private fun resolveTicketFloorSegment(storeId: String?, isDefaultTruckLane: Boolean): String =
if (isDefaultTruckLane) { if (isDefaultTruckLane) {
WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK
@@ -837,7 +882,7 @@ open class DoWorkbenchReleaseService(
} }
} }


private fun setRelationshipIdSelf(headerId: Long) {
private fun setRelationshipIdSelf(headerId: Long, actor: String) {
jdbcDao.executeUpdate( jdbcDao.executeUpdate(
""" """
UPDATE fpsmsdb.delivery_order_pick_order UPDATE fpsmsdb.delivery_order_pick_order
@@ -849,7 +894,7 @@ open class DoWorkbenchReleaseService(
mapOf( mapOf(
"headerId" to headerId, "headerId" to headerId,
"modified" to LocalDateTime.now(), "modified" to LocalDateTime.now(),
"modifiedBy" to "system",
"modifiedBy" to actor,
), ),
) )
} }
@@ -914,10 +959,11 @@ open class DoWorkbenchReleaseService(
isSingleRelease: Boolean, isSingleRelease: Boolean,
requiredDate: LocalDate, requiredDate: LocalDate,
ticketFloorSegment: String, ticketFloorSegment: String,
actor: String,
): Long? { ): Long? {
val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment) val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment)
val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease) val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease)
return insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseType, ticketNo)
return insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseType, ticketNo, actor)
} }


private fun linkPickOrdersToHeader(headerId: Long, group: List<ReleaseDoResult>) { private fun linkPickOrdersToHeader(headerId: Long, group: List<ReleaseDoResult>) {
@@ -941,6 +987,7 @@ open class DoWorkbenchReleaseService(
storeId: String?, storeId: String?,
releaseTypeCol: String, releaseTypeCol: String,
ticketNo: String, ticketNo: String,
actor: String,
): Long? { ): Long? {
val now = LocalDateTime.now() val now = LocalDateTime.now()
jdbcDao.executeUpdate( jdbcDao.executeUpdate(
@@ -970,9 +1017,9 @@ open class DoWorkbenchReleaseService(
"ticketNo" to ticketNo, "ticketNo" to ticketNo,
"releaseType" to releaseTypeCol, "releaseType" to releaseTypeCol,
"created" to now, "created" to now,
"createdBy" to "system",
"createdBy" to actor,
"modified" to now, "modified" to now,
"modifiedBy" to "system",
"modifiedBy" to actor,
) )
) )
return jdbcDao.queryForList( return jdbcDao.queryForList(
@@ -984,7 +1031,7 @@ open class DoWorkbenchReleaseService(
LIMIT 1 LIMIT 1
""".trimIndent(), """.trimIndent(),
mapOf("ticketNo" to ticketNo) mapOf("ticketNo" to ticketNo)
).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it) }
).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it, actor) }
} }


/** /**
@@ -997,6 +1044,7 @@ open class DoWorkbenchReleaseService(
isSingleRelease: Boolean, isSingleRelease: Boolean,
requiredDate: LocalDate, requiredDate: LocalDate,
ticketFloorSegment: String, ticketFloorSegment: String,
actor: String,
): Int { ): Int {
val activeMerge = findActiveMergeTicket(first, storeId, isSingleRelease) val activeMerge = findActiveMergeTicket(first, storeId, isSingleRelease)
if (activeMerge != null) { if (activeMerge != null) {
@@ -1010,14 +1058,14 @@ open class DoWorkbenchReleaseService(
val sourceToRetire = plainBatch ?: legacyUpgraded val sourceToRetire = plainBatch ?: legacyUpgraded


if (sourceToRetire != null) { if (sourceToRetire != null) {
val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment)
val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment, actor)
?: return 0 ?: return 0
retireSourceTicketIntoMergeHeader(sourceToRetire.id, mergeHeaderId) retireSourceTicketIntoMergeHeader(sourceToRetire.id, mergeHeaderId)
linkPickOrdersToHeader(mergeHeaderId, group) linkPickOrdersToHeader(mergeHeaderId, group)
return 1 return 1
} }


val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment)
val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment, actor)
?: return 0 ?: return 0
linkPickOrdersToHeader(mergeHeaderId, group) linkPickOrdersToHeader(mergeHeaderId, group)
return 1 return 1
@@ -1033,6 +1081,7 @@ open class DoWorkbenchReleaseService(
results: List<ReleaseDoResult>, results: List<ReleaseDoResult>,
dopReleaseType: String = "batch", dopReleaseType: String = "batch",
mergeExtraIntoLaneTicket: Boolean = true, mergeExtraIntoLaneTicket: Boolean = true,
actor: String,
): Int { ): Int {
if (results.isEmpty()) return 0 if (results.isEmpty()) return 0


@@ -1064,6 +1113,7 @@ open class DoWorkbenchReleaseService(
isSingleRelease, isSingleRelease,
requiredDate, requiredDate,
ticketFloorSegment, ticketFloorSegment,
actor,
) )
} }


@@ -1079,6 +1129,7 @@ open class DoWorkbenchReleaseService(
storeId, storeId,
WorkbenchReleaseTypeSupport.LEGACY_IS_EXTRA, WorkbenchReleaseTypeSupport.LEGACY_IS_EXTRA,
ticketNo, ticketNo,
actor,
) ?: return 0 ) ?: return 0
linkPickOrdersToHeader(headerId, group) linkPickOrdersToHeader(headerId, group)
return 1 return 1
@@ -1104,7 +1155,7 @@ open class DoWorkbenchReleaseService(
} else { } else {
nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment) nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment)
} }
val headerId = insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseTypeCol, ticketNo)
val headerId = insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseTypeCol, ticketNo, actor)
?: return 0 ?: return 0
linkPickOrdersToHeader(headerId, group) linkPickOrdersToHeader(headerId, group)
return 1 return 1
@@ -1206,6 +1257,7 @@ open class DoWorkbenchReleaseService(
isSingleRelease = isSingleRelease, isSingleRelease = isSingleRelease,
requiredDate = requiredDate, requiredDate = requiredDate,
ticketFloorSegment = ticketFloorSegment, ticketFloorSegment = ticketFloorSegment,
actor = resolveReleaseActor(0L),
) ?: return mergeTicketsError("CREATE_FAILED", "Failed to create TI-M merge ticket") ) ?: return mergeTicketsError("CREATE_FAILED", "Failed to create TI-M merge ticket")
} }


@@ -1473,6 +1525,7 @@ open class DoWorkbenchReleaseService(
isSingleRelease: Boolean, isSingleRelease: Boolean,
requiredDate: LocalDate, requiredDate: LocalDate,
ticketFloorSegment: String, ticketFloorSegment: String,
actor: String,
): Long? { ): Long? {
val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment) val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment)
val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease) val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease)
@@ -1504,9 +1557,9 @@ open class DoWorkbenchReleaseService(
"ticketNo" to ticketNo, "ticketNo" to ticketNo,
"releaseType" to releaseType, "releaseType" to releaseType,
"created" to now, "created" to now,
"createdBy" to "system",
"createdBy" to actor,
"modified" to now, "modified" to now,
"modifiedBy" to "system",
"modifiedBy" to actor,
), ),
) )
return jdbcDao.queryForList( return jdbcDao.queryForList(
@@ -1515,6 +1568,6 @@ open class DoWorkbenchReleaseService(
WHERE ticketNo = :ticketNo ORDER BY id DESC LIMIT 1 WHERE ticketNo = :ticketNo ORDER BY id DESC LIMIT 1
""".trimIndent(), """.trimIndent(),
mapOf("ticketNo" to ticketNo), mapOf("ticketNo" to ticketNo),
).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it) }
).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it, actor) }
} }
} }

+ 1
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt Zobrazit soubor

@@ -347,7 +347,7 @@ open class OnPackTemplateFileService(
LIMIT 1 LIMIT 1
""".trimIndent(), """.trimIndent(),
mapOf("machine" to machine, "code" to itemCode), mapOf("machine" to machine, "code" to itemCode),
).trim().takeIf { it.isNotEmpty() }
).orEmpty().trim().takeIf { it.isNotEmpty() }
} catch (_: DataAccessException) { } catch (_: DataAccessException) {
null null
} }


+ 91
- 34
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt Zobrazit soubor

@@ -69,9 +69,13 @@ data class OnPackZipResult(


private data class OnPackBmpExportItem( private data class OnPackBmpExportItem(
val codeLower: String, val codeLower: String,
/** ZIP / template file stem. First JO stays `pp2211` so operators select that template. */
val fileStem: String,
val itemId: Long, val itemId: Long,
val stockInLineId: Long, val stockInLineId: Long,
val itemCode: String, val itemCode: String,
/** Code BMP text. When that item has 2+ JOs, first bag shows `PP2211-1`. */
val printItemCode: String,
val productName: String, val productName: String,
val planDate: LocalDate?, val planDate: LocalDate?,
) )
@@ -149,6 +153,24 @@ class PlasticBagPrinterService(
fun laserAckLooksInvalid(ack: String?): Boolean = fun laserAckLooksInvalid(ack: String?): Boolean =
ack?.contains("invalid", ignoreCase = true) == true ack?.contains("invalid", ignoreCase = true) == true


/** Expiry 汁水機 ZIP: one file set per job order, ordered by job order no then id. */
fun sortOnPackExportJobOrders(
orders: List<OnPackQrJobOrderRequest>,
jobOrdersById: Map<Long, JobOrder>,
): List<OnPackQrJobOrderRequest> =
orders.sortedWith(
compareBy<OnPackQrJobOrderRequest> { jobOrdersById[it.jobOrderId]?.code?.trim().orEmpty() }
.thenBy { it.jobOrderId },
)

fun onPackExpiryFileStem(itemCodeLower: String, indexOneBased: Int, totalForItem: Int): String =
if (totalForItem > 1 && indexOneBased > 1) "$itemCodeLower-$indexOneBased" else itemCodeLower

fun onPackExpiryPrintCode(itemCode: String, indexOneBased: Int, totalForItem: Int): String {
val code = itemCode.trim().uppercase()
return if (totalForItem > 1) "$code-$indexOneBased" else code
}

/** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */
fun formatLaserExpiryParam(expiryDate: String?): String { fun formatLaserExpiryParam(expiryDate: String?): String {
val raw = expiryDate?.trim().orEmpty() val raw = expiryDate?.trim().orEmpty()
@@ -285,6 +307,37 @@ class PlasticBagPrinterService(
.toSet() .toSet()
} }


private fun toOnPackBmpExportItem(
codeLower: String,
fileStem: String,
order: OnPackQrJobOrderRequest,
expiryPrintNames: Map<String, String>,
jobOrder: JobOrder?,
): OnPackBmpExportItem? {
val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId)
?: return null
val itemId = stockInLine.item?.id ?: return null
val stockInLineId = stockInLine.id ?: return null
val jo = jobOrder ?: jobOrderRepository.findById(order.jobOrderId).orElse(null)
val baseName = jo?.bom?.name ?: stockInLine.item?.name
val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc
val computedName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc)
val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase()
val productName = expiryPrintNames[itemCode]?.trim()?.takeIf { it.isNotEmpty() }
?: computedName
?: itemCode
return OnPackBmpExportItem(
codeLower = codeLower,
fileStem = fileStem,
itemId = itemId,
stockInLineId = stockInLineId,
itemCode = itemCode,
printItemCode = itemCode,
productName = productName,
planDate = jo?.planStart?.toLocalDate(),
)
}

/** /**
* Bag2.py [send_job_to_laser] / [send_job_to_laser_with_retry]: UTF-8 TCP payload and optional ack read. * Bag2.py [send_job_to_laser] / [send_job_to_laser_with_retry]: UTF-8 TCP payload and optional ack read.
*/ */
@@ -725,30 +778,34 @@ class PlasticBagPrinterService(
emptyMap() emptyMap()
} }


val jobOrdersById = jobOrderRepository.findAllById(
packagingJobOrders.map { it.jobOrderId }.distinct(),
).associateBy { it.id!! }
val exportItemsRaw = packagingJobOrders val exportItemsRaw = packagingJobOrders
.groupBy { it.itemCode.trim().lowercase() } .groupBy { it.itemCode.trim().lowercase() }
.mapNotNull { (codeLower, orders) ->
val order = orders.firstOrNull() ?: return@mapNotNull null
val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId)
?: return@mapNotNull null
val itemId = stockInLine.item?.id ?: return@mapNotNull null
val stockInLineId = stockInLine.id ?: return@mapNotNull null
val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null)
val baseName = jo?.bom?.name ?: stockInLine.item?.name
val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc
val computedName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc)
val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase()
val productName = expiryPrintNames[itemCode]?.trim()?.takeIf { it.isNotEmpty() }
?: computedName
?: itemCode
OnPackBmpExportItem(
codeLower,
itemId,
stockInLineId,
itemCode,
productName,
jo?.planStart?.toLocalDate(),
)
.flatMap { (codeLower, orders) ->
val sorted = sortOnPackExportJobOrders(orders, jobOrdersById)
val selected = if (includeExpiry) sorted else listOfNotNull(sorted.firstOrNull())
val items = selected.mapNotNull { order ->
toOnPackBmpExportItem(
codeLower = codeLower,
fileStem = codeLower,
order = order,
expiryPrintNames = expiryPrintNames,
jobOrder = jobOrdersById[order.jobOrderId],
)
}
if (includeExpiry) {
items.mapIndexed { index, item ->
val n = index + 1
item.copy(
fileStem = onPackExpiryFileStem(codeLower, n, items.size),
printItemCode = onPackExpiryPrintCode(item.itemCode, n, items.size),
)
}
} else {
items
}
} }


require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" }
@@ -808,12 +865,12 @@ class PlasticBagPrinterService(
ZipOutputStream(baos).use { zos -> ZipOutputStream(baos).use { zos ->
val addedEntries = linkedSetOf<String>() val addedEntries = linkedSetOf<String>()
exportItems.forEach { item -> exportItems.forEach { item ->
val codeLower = item.codeLower
val fileStem = item.fileStem
val imageTemplate = if (includeExpiry) { val imageTemplate = if (includeExpiry) {
val master = expiryMasterImage ?: return@forEach val master = expiryMasterImage ?: return@forEach
OnPackPp1181Master.rewriteImageBytes(master, codeLower)
OnPackPp1181Master.rewriteImageBytes(master, fileStem)
} else { } else {
loadOnPackImageTemplateOrNull(codeLower, forExpiry = false) ?: return@forEach
loadOnPackImageTemplateOrNull(item.codeLower, forExpiry = false) ?: return@forEach
} }


val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}""" val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}"""
@@ -821,20 +878,20 @@ class PlasticBagPrinterService(
// Width = 386 + (42 * 2) = 470 // Width = 386 + (42 * 2) = 470
// Height = 386 + (1 * 2) = 388 (~389) // Height = 386 + (1 * 2) = 388 (~389)
val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1) val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1)
val qrBmpFileName = "${codeLower}qr.bmp"
val imageFileName = "$codeLower.image"
val qrBmpFileName = "${fileStem}qr.bmp"
val imageFileName = "$fileStem.image"
var imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName) var imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName)
if (includeExpiry) { if (includeExpiry) {
val productFile = "${codeLower}Product.bmp"
val codeFile = "${codeLower}Code.bmp"
val dateFile = "${codeLower}Date.bmp"
val productFile = "${fileStem}Product.bmp"
val codeFile = "${fileStem}Code.bmp"
val dateFile = "${fileStem}Date.bmp"
val productBmp = createMonochromeBitmapFixed( val productBmp = createMonochromeBitmapFixed(
item.productName, item.productName,
ONPACK_PRODUCT_BMP_WIDTH, ONPACK_PRODUCT_BMP_WIDTH,
ONPACK_PRODUCT_BMP_HEIGHT, ONPACK_PRODUCT_BMP_HEIGHT,
) )
val codeBmp = createMonochromeBitmapFixed( val codeBmp = createMonochromeBitmapFixed(
item.itemCode,
item.printItemCode,
ONPACK_CODE_BMP_WIDTH, ONPACK_CODE_BMP_WIDTH,
ONPACK_CODE_BMP_HEIGHT, ONPACK_CODE_BMP_HEIGHT,
) )
@@ -857,7 +914,7 @@ class PlasticBagPrinterService(
} }
} }
val expiryLabel = if (includeExpiry) { val expiryLabel = if (includeExpiry) {
itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate)
itemDefaultShelfLifeService.expiryDatePrintLabel(item.itemCode, effectivePrintDate)
} else { } else {
null null
} }
@@ -867,7 +924,7 @@ class PlasticBagPrinterService(
ONPACK_EXPIRY_BMP_WIDTH, ONPACK_EXPIRY_BMP_WIDTH,
ONPACK_EXPIRY_BMP_HEIGHT, ONPACK_EXPIRY_BMP_HEIGHT,
) )
val expBmpFileName = "${codeLower}exp.bmp"
val expBmpFileName = "${fileStem}exp.bmp"
imageContent = withOnPackExpiryLogo(imageContent, expBmpFileName, expBmp.width) imageContent = withOnPackExpiryLogo(imageContent, expBmpFileName, expBmp.width)
if (addedEntries.add(expBmpFileName)) { if (addedEntries.add(expBmpFileName)) {
addToZip(zos, expBmpFileName, expBmp.bytes) addToZip(zos, expBmpFileName, expBmp.bytes)
@@ -893,7 +950,7 @@ class PlasticBagPrinterService(
addToZip(zos, bmpName, bmpBytes) addToZip(zos, bmpName, bmpBytes)
} }
} }
val jobFileName = "${codeLower}.job"
val jobFileName = "$fileStem.job"
if (includeExpiry) { if (includeExpiry) {
val masterJob = expiryMasterJob val masterJob = expiryMasterJob
if (masterJob != null && addedEntries.add(jobFileName)) { if (masterJob != null && addedEntries.add(jobFileName)) {


+ 18
- 8
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt Zobrazit soubor

@@ -1814,24 +1814,30 @@ open fun getBadItemOnlyList(): List<PickExecutionIssue> {
return pickExecutionIssueRepository.findBadItemOnlyList(IssueCategory.lot_issue) return pickExecutionIssueRepository.findBadItemOnlyList(IssueCategory.lot_issue)
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
open fun getExpiryItemList( open fun getExpiryItemList(
expiryDate: LocalDate? = null,
itemCode: String? = null, itemCode: String? = null,
itemName: String? = null, itemName: String? = null,
lotNo: String? = null,
daysAhead: Int? = null,
): List<ExpiryItemResponse> { ): List<ExpiryItemResponse> {
val today = LocalDate.now() val today = LocalDate.now()
val days = (daysAhead ?: 7).coerceIn(0, 365)
val untilDate = today.plusDays(days.toLong())
val normalizedItemCode = itemCode?.trim()?.takeIf { it.isNotEmpty() } val normalizedItemCode = itemCode?.trim()?.takeIf { it.isNotEmpty() }
val normalizedItemName = itemName?.trim()?.takeIf { it.isNotEmpty() } val normalizedItemName = itemName?.trim()?.takeIf { it.isNotEmpty() }
val normalizedLotNo = lotNo?.trim()?.takeIf { it.isNotEmpty() }
val lotLines = inventoryLotLineRepository.findExpiredItems( val lotLines = inventoryLotLineRepository.findExpiredItems(
today = today,
expiryDate = expiryDate,
untilDate = untilDate,
itemCode = normalizedItemCode, itemCode = normalizedItemCode,
itemName = normalizedItemName, itemName = normalizedItemName,
lotNo = normalizedLotNo,
) )
return lotLines.map { lotLine -> return lotLines.map { lotLine ->
val lot = lotLine.inventoryLot val lot = lotLine.inventoryLot
val item = lot?.item // Get item from inventoryLot val item = lot?.item // Get item from inventoryLot
val expiry = lot?.expiryDate
ExpiryItemResponse( ExpiryItemResponse(
id = lotLine.id ?: 0L, id = lotLine.id ?: 0L,
itemId = item?.id ?: 0L, itemId = item?.id ?: 0L,
@@ -1840,8 +1846,10 @@ open fun getExpiryItemList(
lotId = lot?.id ?: 0L, lotId = lot?.id ?: 0L,
lotNo = lot?.lotNo, lotNo = lot?.lotNo,
storeLocation = lotLine.warehouse?.code, // Construct from warehouse storeLocation = lotLine.warehouse?.code, // Construct from warehouse
expiryDate = lot?.expiryDate,
remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO)
expiryDate = expiry,
remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO),
uomDesc = lotLine.stockUom?.uom?.udfudesc,
canHandle = expiry != null && !expiry.isAfter(today),
) )
} }
} }
@@ -1993,6 +2001,7 @@ open fun submitBadItem(request: SubmitIssueRequest): MessageResponse {
} }
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@Transactional(rollbackFor = [Exception::class]) @Transactional(rollbackFor = [Exception::class])
open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse { open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
try { try {
@@ -2011,7 +2020,7 @@ open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
val lot = lotLine.inventoryLot val lot = lotLine.inventoryLot
val today = LocalDate.now() val today = LocalDate.now()
if (lot?.expiryDate == null || !lot.expiryDate!!.isBefore(today)) {
if (lot?.expiryDate == null || lot.expiryDate!!.isAfter(today)) {
return MessageResponse( return MessageResponse(
id = null, id = null,
name = "Error", name = "Error",
@@ -2201,6 +2210,7 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse {
} }
} }
// Fix batchSubmitExpiryItem method (around line 945): // Fix batchSubmitExpiryItem method (around line 945):
/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@Transactional(rollbackFor = [Exception::class]) @Transactional(rollbackFor = [Exception::class])
open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse { open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse {
try { try {
@@ -2209,8 +2219,8 @@ open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageRespon
val lot = it.inventoryLot val lot = it.inventoryLot
val today = LocalDate.now() val today = LocalDate.now()
lot?.expiryDate != null && lot?.expiryDate != null &&
lot.expiryDate!!.isBefore(today) &&
(it.inQty ?: BigDecimal.ZERO) != (it.outQty ?: BigDecimal.ZERO)
lot.expiryDate!!.let { !it.isAfter(today) } &&
(it.inQty ?: BigDecimal.ZERO) > (it.outQty ?: BigDecimal.ZERO)
} }


if (lotLines.isEmpty()) { if (lotLines.isEmpty()) {


+ 35
- 5
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt Zobrazit soubor

@@ -6,15 +6,18 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssue
import com.ffii.fpsms.modules.pickOrder.enums.PickExecutionIssueEnum import com.ffii.fpsms.modules.pickOrder.enums.PickExecutionIssueEnum
import com.ffii.fpsms.modules.pickOrder.service.PickExecutionIssueService // 修复导入路径 import com.ffii.fpsms.modules.pickOrder.service.PickExecutionIssueService // 修复导入路径
import com.ffii.fpsms.modules.pickOrder.web.models.* import com.ffii.fpsms.modules.pickOrder.web.models.*
import org.springframework.format.annotation.DateTimeFormat
import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService
import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters
import com.ffii.fpsms.modules.report.web.lotExpiryAlertExcelResponse
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import java.time.LocalDate




@RestController @RestController
@RequestMapping("/pickExecution") @RequestMapping("/pickExecution")
class PickExecutionIssueController( class PickExecutionIssueController(
private val pickExecutionIssueService: PickExecutionIssueService
private val pickExecutionIssueService: PickExecutionIssueService,
private val lotExpiryAlertReportService: LotExpiryAlertReportService,
) { ) {


@PostMapping("/recordIssue") @PostMapping("/recordIssue")
@@ -66,19 +69,44 @@ class PickExecutionIssueController(
return pickExecutionIssueService.getBadItemList(issueCategory) return pickExecutionIssueService.getBadItemList(issueCategory)
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@GetMapping("/issues/expiryItem") @GetMapping("/issues/expiryItem")
fun getExpiryItemIssues( fun getExpiryItemIssues(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) expiryDate: LocalDate?,
@RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) itemName: String?, @RequestParam(required = false) itemName: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) daysAhead: Int?,
): List<ExpiryItemResponse> { ): List<ExpiryItemResponse> {
return pickExecutionIssueService.getExpiryItemList( return pickExecutionIssueService.getExpiryItemList(
expiryDate = expiryDate,
itemCode = itemCode, itemCode = itemCode,
itemName = itemName, itemName = itemName,
lotNo = lotNo,
daysAhead = daysAhead,
) )
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@GetMapping("/issues/expiryItem/excel")
fun exportExpiryItemExcel(
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) itemName: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) bucket: String?,
@RequestParam(required = false) daysAhead: Int?,
): ResponseEntity<ByteArray> {
return lotExpiryAlertExcelResponse(
lotExpiryAlertReportService.exportExcel(
SearchFilters(
itemCode = itemCode,
itemName = itemName,
lotNo = lotNo,
bucket = bucket,
daysAhead = daysAhead,
),
),
)
}

@PostMapping("/submitMissItem") @PostMapping("/submitMissItem")
fun submitMissItem(@RequestBody request: SubmitIssueRequest): MessageResponse { fun submitMissItem(@RequestBody request: SubmitIssueRequest): MessageResponse {
return pickExecutionIssueService.submitMissItem(request) return pickExecutionIssueService.submitMissItem(request)
@@ -99,11 +127,13 @@ fun batchSubmitBadItem(@RequestBody request: BatchSubmitIssueRequest): MessageRe
return pickExecutionIssueService.batchSubmitBadItem(request) return pickExecutionIssueService.batchSubmitBadItem(request)
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@PostMapping("/submitExpiryItem") @PostMapping("/submitExpiryItem")
fun submitExpiryItem(@RequestBody request: SubmitExpiryRequest): MessageResponse { fun submitExpiryItem(@RequestBody request: SubmitExpiryRequest): MessageResponse {
return pickExecutionIssueService.submitExpiryItem(request) return pickExecutionIssueService.submitExpiryItem(request)
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@PostMapping("/batchSubmitExpiryItem") @PostMapping("/batchSubmitExpiryItem")
fun batchSubmitExpiryItem(@RequestBody request: BatchSubmitExpiryRequest): MessageResponse { fun batchSubmitExpiryItem(@RequestBody request: BatchSubmitExpiryRequest): MessageResponse {
return pickExecutionIssueService.batchSubmitExpiryItem(request) return pickExecutionIssueService.batchSubmitExpiryItem(request)


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt Zobrazit soubor

@@ -31,6 +31,9 @@ data class ExpiryItemResponse(
val storeLocation: String?, val storeLocation: String?,
val expiryDate: LocalDate?, val expiryDate: LocalDate?,
val remainingQty: BigDecimal, val remainingQty: BigDecimal,
val uomDesc: String?,
/** True when expiryDate is today or earlier. */
val canHandle: Boolean,
) )
data class LotIssueDetailRequest( data class LotIssueDetailRequest(
val lotId: Long, val lotId: Long,


+ 247
- 0
src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt Zobrazit soubor

@@ -0,0 +1,247 @@
package com.ffii.fpsms.modules.report.service

import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.CellStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Row
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.streaming.SXSSFWorkbook
import java.io.ByteArrayOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

internal object LotExpiryAlertExcelBuilder {
private data class ExcelStyles(
val title: CellStyle,
val subtitle: CellStyle,
val header: CellStyle,
val text: CellStyle,
val center: CellStyle,
val number: CellStyle,
)

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
fun build(
dbData: List<Map<String, Any>>,
filters: SearchFilters,
): ByteArray {
val workbook = SXSSFWorkbook(100)
workbook.setCompressTempFiles(true)
try {
val styles = createStyles(workbook)
val reportTitle = "到期警示報告"
val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle))
val headers = listOf(
"到期警示",
"樓層",
"到期日",
"貨品編號",
"貨品名稱",
"批號",
"倉庫",
"區域",
"儲位",
"剩餘數量",
"單位",
)
val totalColumns = headers.size
var rowIndex = 0

val titleRow = sheet.createRow(rowIndex++)
titleRow.heightInPoints = 24f
titleRow.createCell(0).apply {
setCellValue(reportTitle)
cellStyle = styles.title
}
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

val downloadedAt = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
val dateRow = sheet.createRow(rowIndex++)
dateRow.heightInPoints = 32f
dateRow.createCell(0).apply {
setCellValue("下載日期:$downloadedAt")
cellStyle = styles.subtitle
}
val dateEndCol = 3.coerceAtMost(totalColumns - 2)
if (dateEndCol > 0) {
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, dateEndCol))
}
val filterStartCol = dateEndCol + 1
val filterText = listOf(
"貨品編號=${displayFilter(filters.itemCode)}",
"貨品=${displayFilter(filters.itemName)}",
"批號=${displayFilter(filters.lotNo)}",
"未來天數=${filters.resolvedDaysAhead()}",
"到期分類=${displayBucket(filters.bucket, filters.resolvedDaysAhead())}",
).joinToString(" ")
dateRow.createCell(filterStartCol).apply {
setCellValue("搜尋條件:$filterText")
cellStyle = styles.subtitle
}
if (filterStartCol < totalColumns - 1) {
sheet.addMergedRegion(CellRangeAddress(1, 1, filterStartCol, totalColumns - 1))
}

val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headerRow.heightInPoints = 22f
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
}
}

if (dbData.isEmpty()) {
val emptyRowIndex = rowIndex
val r = sheet.createRow(rowIndex++)
r.heightInPoints = 22f
r.createCell(0).apply {
setCellValue("查無資料")
cellStyle = styles.center
}
for (c in 1 until totalColumns) {
r.createCell(c).cellStyle = styles.center
}
sheet.addMergedRegion(CellRangeAddress(emptyRowIndex, emptyRowIndex, 0, totalColumns - 1))
} else {
dbData.forEach { m ->
val r = sheet.createRow(rowIndex++)
r.heightInPoints = 18f
setTextCell(r, 0, m["expiryAlert"], styles.center)
setTextCell(r, 1, m["storeId"], styles.center)
setTextCell(r, 2, m["expiryDate"], styles.center)
setTextCell(r, 3, m["itemCode"], styles.text)
setTextCell(r, 4, m["itemName"], styles.text)
setTextCell(r, 5, m["lotNumber"], styles.text)
setTextCell(r, 6, m["warehouse"], styles.center)
setTextCell(r, 7, m["area"], styles.center)
setTextCell(r, 8, m["slot"], styles.center)
setNumberCell(r, 9, m["remainingQty"], styles.number)
setTextCell(r, 10, m["unitOfMeasure"], styles.center)
}
}

val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, totalColumns - 1))
}
sheet.createFreezePane(0, headerRowIndex + 1)
intArrayOf(14, 10, 12, 14, 28, 18, 12, 10, 10, 12, 16)
.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.write(out)
return out.toByteArray()
} finally {
workbook.dispose()
workbook.close()
}
}

private fun createStyles(workbook: Workbook): ExcelStyles {
val numberFormat = workbook.createDataFormat().getFormat("#,##0.00;(#,##0.00)")
fun borders(style: CellStyle) {
style.borderTop = BorderStyle.THIN
style.borderBottom = BorderStyle.THIN
style.borderLeft = BorderStyle.THIN
style.borderRight = BorderStyle.THIN
style.verticalAlignment = VerticalAlignment.CENTER
}
val titleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
bold = true
fontHeightInPoints = 16
}
setFont(font)
}
val subtitleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
wrapText = true
val font = workbook.createFont().apply {
fontHeightInPoints = 10
}
setFont(font)
}
val headerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
wrapText = true
borders(this)
fillForegroundColor = IndexedColors.DARK_TEAL.index
fillPattern = FillPatternType.SOLID_FOREGROUND
val font = workbook.createFont().apply {
bold = true
color = IndexedColors.WHITE.index
fontHeightInPoints = 10
}
setFont(font)
}
val textStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
borders(this)
}
val centerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
borders(this)
}
val numberStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
borders(this)
dataFormat = numberFormat
}
return ExcelStyles(
title = titleStyle,
subtitle = subtitleStyle,
header = headerStyle,
text = textStyle,
center = centerStyle,
number = numberStyle,
)
}

private fun displayFilter(raw: String?): String {
val v = raw?.trim().orEmpty()
return if (v.isEmpty()) "全部" else v
}

private fun displayBucket(raw: String?, daysAhead: Int): String {
return when (raw?.trim()?.lowercase()) {
"expired" -> "過期尚未處理"
"today" -> "今日到期"
"upcoming" -> "未來 ${daysAhead} 日到期"
else -> "全部"
}
}

private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) {
row.createCell(col).apply {
setCellValue(value?.toString() ?: "")
cellStyle = style
}
}

private fun setNumberCell(row: Row, col: Int, value: Any?, numberStyle: CellStyle) {
val cell = row.createCell(col)
val n = when (value) {
null -> null
is Number -> value.toDouble()
else -> value.toString().replace(",", "").toDoubleOrNull()
}
if (n == null) {
cell.setCellValue("")
cell.cellStyle = numberStyle
return
}
cell.setCellValue(n)
cell.cellStyle = numberStyle
}
}

+ 126
- 0
src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertReportService.kt Zobrazit soubor

@@ -0,0 +1,126 @@
package com.ffii.fpsms.modules.report.service

import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Service

@Service
open class LotExpiryAlertReportService(
private val jdbcDao: JdbcDao,
) {
data class SearchFilters(
val itemCode: String? = null,
val itemName: String? = null,
val lotNo: String? = null,
val bucket: String? = null,
val daysAhead: Int? = null,
) {
fun resolvedDaysAhead(): Int {
val n = daysAhead ?: DEFAULT_DAYS_AHEAD
return n.coerceIn(0, MAX_DAYS_AHEAD)
}

companion object {
const val DEFAULT_DAYS_AHEAD = 7
const val MAX_DAYS_AHEAD = 365
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
fun search(
itemCode: String? = null,
itemName: String? = null,
lotNo: String? = null,
bucket: String? = null,
daysAhead: Int? = null,
): List<Map<String, Any>> = search(
SearchFilters(
itemCode = itemCode,
itemName = itemName,
lotNo = lotNo,
bucket = bucket,
daysAhead = daysAhead,
),
)

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
fun search(filters: SearchFilters): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val daysAhead = filters.resolvedDaysAhead()
args["daysAhead"] = daysAhead
val extraWhere = buildString {
val itemCode = filters.itemCode?.trim().orEmpty()
if (itemCode.isNotEmpty()) {
args["itemCode"] = itemCode
append(" AND LOWER(i.code) LIKE LOWER(CONCAT('%', :itemCode, '%'))")
}
val itemName = filters.itemName?.trim().orEmpty()
if (itemName.isNotEmpty()) {
args["itemName"] = itemName
append(" AND LOWER(i.name) LIKE LOWER(CONCAT('%', :itemName, '%'))")
}
val lotNo = filters.lotNo?.trim().orEmpty()
if (lotNo.isNotEmpty()) {
args["lotNo"] = lotNo
append(" AND LOWER(il.lotNo) LIKE LOWER(CONCAT('%', :lotNo, '%'))")
}
when (filters.bucket?.trim()?.lowercase()) {
"expired" -> append(" AND il.expiryDate < CURRENT_DATE")
"today" -> append(" AND il.expiryDate = CURRENT_DATE")
"upcoming" -> append(
" AND il.expiryDate > CURRENT_DATE AND il.expiryDate <= DATE_ADD(CURRENT_DATE, INTERVAL :daysAhead DAY)",
)
}
}
return jdbcDao.queryForList(
"""
SELECT
COALESCE(i.code, '') AS itemCode,
COALESCE(i.name, '') AS itemName,
COALESCE(il.lotNo, '') AS lotNumber,
COALESCE(w.store_id, '') AS storeId,
COALESCE(w.warehouse, '') AS warehouse,
COALESCE(w.area, '') AS area,
COALESCE(w.slot, '') AS slot,
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS remainingQty,
COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure,
CASE
WHEN il.expiryDate IS NULL THEN ''
WHEN il.expiryDate < CURRENT_DATE THEN '已過期'
WHEN il.expiryDate = CURRENT_DATE THEN '今日到期'
WHEN il.expiryDate <= DATE_ADD(CURRENT_DATE, INTERVAL :daysAhead DAY)
THEN '${daysAhead}日內到期'
ELSE ''
END AS expiryAlert
FROM inventory_lot_line ill
LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id
LEFT JOIN warehouse w ON ill.warehouseId = w.id
LEFT JOIN items i ON il.itemId = i.id
LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0
AND COALESCE(ill.deleted, 0) = 0
AND COALESCE(il.deleted, 0) = 0
AND COALESCE(i.deleted, 0) = 0
AND il.expiryDate IS NOT NULL
AND il.expiryDate <= DATE_ADD(CURRENT_DATE, INTERVAL :daysAhead DAY)
$extraWhere
ORDER BY
CASE
WHEN il.expiryDate < CURRENT_DATE THEN 1
WHEN il.expiryDate = CURRENT_DATE THEN 2
ELSE 3
END,
COALESCE(w.store_id, ''),
il.expiryDate,
COALESCE(i.code, '')
""".trimIndent(),
args,
) as List<Map<String, Any>>
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
fun exportExcel(filters: SearchFilters = SearchFilters()): ByteArray {
return LotExpiryAlertExcelBuilder.build(search(filters), filters)
}
}

+ 40
- 9
src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt Zobrazit soubor

@@ -9,6 +9,8 @@ import java.time.format.DateTimeFormatter
* 庫存批次現況(Stock Balance):永遠今天。 * 庫存批次現況(Stock Balance):永遠今天。
* 現存讀 [inventory_lot_line](available、未過期、in-out > 0)。 * 現存讀 [inventory_lot_line](available、未過期、in-out > 0)。
* 最後異動:有 inventoryLotLineId 的帳本用 MAX(id);缺的比 SIL/SOL 時間。 * 最後異動:有 inventoryLotLineId 的帳本用 MAX(id);缺的比 SIL/SOL 時間。
* 單位均價:貨品 AverageUnitPrice(HKD/stock UOM)用 item_uom 比率轉成該 lot 單位;
* 同一貨品同一 UOM 共用同一均價。庫存總價值 = 該 UOM 均價 × lot 數量。
* 單位均價/庫存總價值只填 root PO 為 PP/PF 的批(TRF 往回走);其他來源空白。 * 單位均價/庫存總價值只填 root PO 為 PP/PF 的批(TRF 往回走);其他來源空白。
*/ */
@Service @Service
@@ -70,6 +72,19 @@ lot_root_origin AS (
WHERE t.rn = 1 WHERE t.rn = 1
)""" )"""


/** One stock-unit item_uom per item (same pick as item average-price SQL). */
private const val STOCK_ONE_CTE_SQL = """
stock_one AS (
SELECT iu.*
FROM item_uom iu
INNER JOIN (
SELECT itemId, MIN(id) AS id
FROM item_uom
WHERE deleted = 0 AND stockUnit = 1
GROUP BY itemId
) x ON x.id = iu.id
)"""

private const val ROOT_STOCK_IN_JOIN_SQL = """ private const val ROOT_STOCK_IN_JOIN_SQL = """
LEFT JOIN lot_root_origin lro LEFT JOIN lot_root_origin lro
ON lro.lotId = il.id ON lro.lotId = il.id
@@ -85,7 +100,7 @@ lot_root_origin AS (
val stockDate: String, val stockDate: String,
) )


/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */
/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */
fun search( fun search(
itemCode: String?, itemCode: String?,
storeId: String?, storeId: String?,
@@ -133,12 +148,13 @@ lot_root_origin AS (
} }
val lotOriginFilterSql = buildLotOriginFilterSql(lotOrigin) val lotOriginFilterSql = buildLotOriginFilterSql(lotOrigin)
val originJoinSql = ROOT_STOCK_IN_JOIN_SQL val originJoinSql = ROOT_STOCK_IN_JOIN_SQL
val ctePrefix = "WITH RECURSIVE\n$LOT_ROOT_ORIGIN_CTE_SQL\n"
val ctePrefix = "WITH RECURSIVE\n$LOT_ROOT_ORIGIN_CTE_SQL,\n$STOCK_ONE_CTE_SQL\n"


val liveLots = jdbcDao.queryForList( val liveLots = jdbcDao.queryForList(
""" """
${ctePrefix}SELECT ${ctePrefix}SELECT
ill.id AS inventoryLotLineId, ill.id AS inventoryLotLineId,
COALESCE(NULLIF(TRIM(ill.remarks), ''), '') AS remarks,
COALESCE(it.code, '') AS itemNo, COALESCE(it.code, '') AS itemNo,
COALESCE(it.name, '') AS itemName, COALESCE(it.name, '') AS itemName,
COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure,
@@ -149,7 +165,19 @@ lot_root_origin AS (
COALESCE(wh.area, '') AS areaPart, COALESCE(wh.area, '') AS areaPart,
COALESCE(wh.slot, '') AS slotPart, COALESCE(wh.slot, '') AS slotPart,
(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS lotQtyRaw, (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS lotQtyRaw,
COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw,
CASE
WHEN iu.id IS NULL OR so.id IS NULL THEN
COALESCE(CAST(NULLIF(TRIM(it.AverageUnitPrice), '') AS DECIMAL(14, 4)), 0)
WHEN iu.uomId = so.uomId THEN
COALESCE(CAST(NULLIF(TRIM(it.AverageUnitPrice), '') AS DECIMAL(14, 4)), 0)
ELSE
ROUND(
COALESCE(CAST(NULLIF(TRIM(it.AverageUnitPrice), '') AS DECIMAL(14, 4)), 0)
* (COALESCE(iu.ratioN, 1) / COALESCE(NULLIF(iu.ratioD, 0), 1))
* (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)),
4
)
END AS avgUnitPriceRaw,
UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) AS rootPoPrefix UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) AS rootPoPrefix
FROM inventory_lot_line ill FROM inventory_lot_line ill
INNER JOIN inventory_lot il INNER JOIN inventory_lot il
@@ -162,6 +190,8 @@ lot_root_origin AS (
ON iu.id = ill.stockItemUomId AND iu.deleted = 0 ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc LEFT JOIN uom_conversion uc
ON uc.id = iu.uomId ON uc.id = iu.uomId
LEFT JOIN stock_one so
ON so.itemId = it.id
$originJoinSql $originJoinSql
WHERE ill.deleted = 0 WHERE ill.deleted = 0
AND it.code IS NOT NULL AND it.code <> '' AND it.code IS NOT NULL AND it.code <> ''
@@ -200,7 +230,7 @@ lot_root_origin AS (


private data class SilSolHit(val ts: String, val date: String, val kind: String) private data class SilSolHit(val ts: String, val date: String, val kind: String)


/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */
/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */
private fun loadLastTrnFromLedger(lotIds: List<Long>, out: MutableMap<Long, Pair<String, String>>) { private fun loadLastTrnFromLedger(lotIds: List<Long>, out: MutableMap<Long, Pair<String, String>>) {
if (lotIds.isEmpty()) return if (lotIds.isEmpty()) return
// idx_ledger_lot_date_id is (lot, date, id) — bad for MAX(id); force the lot-only index // idx_ledger_lot_date_id is (lot, date, id) — bad for MAX(id); force the lot-only index
@@ -233,7 +263,7 @@ lot_root_origin AS (
} }
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */
/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */
private fun fillLastTrnFromLotHeaderSil(missing: List<Long>, hits: MutableMap<Long, SilSolHit>): Int { private fun fillLastTrnFromLotHeaderSil(missing: List<Long>, hits: MutableMap<Long, SilSolHit>): Int {
var n = 0 var n = 0
for (chunk in missing.chunked(800)) { for (chunk in missing.chunked(800)) {
@@ -259,7 +289,7 @@ lot_root_origin AS (
return n return n
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */
/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */
private fun fillLastTrnFromSilLine(missing: List<Long>, hits: MutableMap<Long, SilSolHit>): Int { private fun fillLastTrnFromSilLine(missing: List<Long>, hits: MutableMap<Long, SilSolHit>): Int {
var n = 0 var n = 0
for (chunk in missing.chunked(800)) { for (chunk in missing.chunked(800)) {
@@ -281,7 +311,7 @@ lot_root_origin AS (
return n return n
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */
/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */
private fun fillLastTrnFromSol(missing: List<Long>, hits: MutableMap<Long, SilSolHit>): Int { private fun fillLastTrnFromSol(missing: List<Long>, hits: MutableMap<Long, SilSolHit>): Int {
var n = 0 var n = 0
for (chunk in missing.chunked(800)) { for (chunk in missing.chunked(800)) {
@@ -325,7 +355,7 @@ lot_root_origin AS (
else -> "" else -> ""
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */
/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */
private fun assembleRows( private fun assembleRows(
liveLots: List<Map<String, Any>>, liveLots: List<Map<String, Any>>,
lastTrnByLot: Map<Long, Pair<String, String>>, lastTrnByLot: Map<Long, Pair<String, String>>,
@@ -344,8 +374,9 @@ lot_root_origin AS (
val last = lastTrnByLot[lotId] val last = lastTrnByLot[lotId]
val origin = r["rootPoPrefix"]?.toString().orEmpty().uppercase() val origin = r["rootPoPrefix"]?.toString().orEmpty().uppercase()
val showPrice = origin == "PP" || origin == "PF" val showPrice = origin == "PP" || origin == "PF"
val row = HashMap<String, Any>(22)
val row = HashMap<String, Any>(24)
row["inventoryLotLineId"] = lotId.toString() row["inventoryLotLineId"] = lotId.toString()
row["remarks"] = r["remarks"] ?: ""
row["itemNo"] = r["itemNo"] ?: "" row["itemNo"] = r["itemNo"] ?: ""
row["itemName"] = r["itemName"] ?: "" row["itemName"] = r["itemName"] ?: ""
row["unitOfMeasure"] = r["unitOfMeasure"] ?: "" row["unitOfMeasure"] = r["unitOfMeasure"] ?: ""


+ 54
- 0
src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt Zobrazit soubor

@@ -0,0 +1,54 @@
package com.ffii.fpsms.modules.report.web

import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService
import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

internal fun lotExpiryAlertExcelResponse(bytes: ByteArray): ResponseEntity<ByteArray> {
val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
setContentDispositionFormData("attachment", "LotExpiryAlertReport.xlsx")
set("filename", "LotExpiryAlertReport.xlsx")
}
return ResponseEntity(bytes, headers, HttpStatus.OK)
}

/**
* Lot expiry alert (Excel only).
* Excel: /report/print-lot-expiry-alert-excel
*/
@RestController
@RequestMapping("/report")
class LotExpiryAlertReportController(
private val lotExpiryAlertReportService: LotExpiryAlertReportService,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@GetMapping("/print-lot-expiry-alert-excel")
fun exportExcel(
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) itemName: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) bucket: String?,
@RequestParam(required = false) daysAhead: Int?,
): ResponseEntity<ByteArray> {
val excelBytes = lotExpiryAlertReportService.exportExcel(
SearchFilters(
itemCode = itemCode,
itemName = itemName,
lotNo = lotNo,
bucket = bucket,
daysAhead = daysAhead,
),
)
return lotExpiryAlertExcelResponse(excelBytes)
}
}

+ 4
- 3
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt Zobrazit soubor

@@ -571,11 +571,12 @@ class ReportController(
numberStyle: XSSFCellStyle, numberStyle: XSSFCellStyle,
dashStyle: XSSFCellStyle, dashStyle: XSSFCellStyle,
preferInt: Boolean = false, preferInt: Boolean = false,
emptyAsBlank: Boolean = false,
) { ) {
val cell = row.createCell(col) val cell = row.createCell(col)
val parsed = parseSignedNumber(value) val parsed = parseSignedNumber(value)
if (parsed == null) { if (parsed == null) {
cell.setCellValue("-")
cell.setCellValue(if (emptyAsBlank) "" else "-")
cell.cellStyle = dashStyle cell.cellStyle = dashStyle
return return
} }
@@ -962,8 +963,8 @@ class ReportController(
setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.int, styles.dash, preferInt = true) setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.int, styles.dash, preferInt = true) setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.int, styles.dash, preferInt = true) setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true)
setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true)
} }
} }




+ 2
- 1
src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt Zobrazit soubor

@@ -43,7 +43,7 @@ class StockLotOnhandReportController(
val number: CellStyle, val number: CellStyle,
) )


/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */
/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */
@GetMapping("/print-stock-lot-onhand-excel") @GetMapping("/print-stock-lot-onhand-excel")
fun exportExcel( fun exportExcel(
@RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemCode: String?,
@@ -191,6 +191,7 @@ class StockLotOnhandReportController(
return v return v
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */
private fun createExcel( private fun createExcel(
dbData: List<Map<String, Any>>, dbData: List<Map<String, Any>>,
reportDate: String, reportDate: String,


+ 8
- 6
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt Zobrazit soubor

@@ -220,27 +220,29 @@ WHERE ill.id = :id


@EntityGraph( @EntityGraph(
type = EntityGraph.EntityGraphType.FETCH, type = EntityGraph.EntityGraphType.FETCH,
attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"]
attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse", "stockUom", "stockUom.uom"]
) )
@Query(""" @Query("""
SELECT ill FROM InventoryLotLine ill SELECT ill FROM InventoryLotLine ill
JOIN ill.inventoryLot il JOIN ill.inventoryLot il
JOIN il.item i JOIN il.item i
WHERE il.expiryDate < :today
AND (:expiryDate IS NULL OR il.expiryDate = :expiryDate)
WHERE il.expiryDate IS NOT NULL
AND il.expiryDate <= :untilDate
AND (:itemCode IS NULL OR LOWER(i.code) LIKE LOWER(CONCAT('%', :itemCode, '%'))) AND (:itemCode IS NULL OR LOWER(i.code) LIKE LOWER(CONCAT('%', :itemCode, '%')))
AND (:itemName IS NULL OR LOWER(i.name) LIKE LOWER(CONCAT('%', :itemName, '%'))) AND (:itemName IS NULL OR LOWER(i.name) LIKE LOWER(CONCAT('%', :itemName, '%')))
AND coalesce(ill.inQty, 0) <> coalesce(ill.outQty, 0)
AND (:lotNo IS NULL OR LOWER(il.lotNo) LIKE LOWER(CONCAT('%', :lotNo, '%')))
AND coalesce(ill.inQty, 0) > coalesce(ill.outQty, 0)
AND ill.deleted = false AND ill.deleted = false
AND il.deleted = false AND il.deleted = false
AND i.deleted = false AND i.deleted = false
ORDER BY il.expiryDate ASC ORDER BY il.expiryDate ASC
""") """)
/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
fun findExpiredItems( fun findExpiredItems(
@Param("today") today: LocalDate,
@Param("expiryDate") expiryDate: LocalDate?,
@Param("untilDate") untilDate: LocalDate,
@Param("itemCode") itemCode: String?, @Param("itemCode") itemCode: String?,
@Param("itemName") itemName: String?, @Param("itemName") itemName: String?,
@Param("lotNo") lotNo: String?,
): List<InventoryLotLine> ): List<InventoryLotLine>


/** /**


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecord.kt Zobrazit soubor

@@ -31,6 +31,9 @@ open class StockAdjustmentRecord : BaseEntity<Long>() {
@Column(name = "outQty", precision = 14, scale = 2) @Column(name = "outQty", precision = 14, scale = 2)
open var outQty: BigDecimal? = null open var outQty: BigDecimal? = null


@Column(name = "remarks", length = 500)
open var remarks: String? = null

@JsonBackReference @JsonBackReference
@ManyToOne @ManyToOne
@JoinColumn(name = "stockInLineId") @JoinColumn(name = "stockInLineId")


+ 14
- 1
src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecordRepository.kt Zobrazit soubor

@@ -1,7 +1,20 @@
package com.ffii.fpsms.modules.stock.entity package com.ffii.fpsms.modules.stock.entity


import com.ffii.core.support.AbstractRepository import com.ffii.core.support.AbstractRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository


@Repository @Repository
interface StockAdjustmentRecordRepository : AbstractRepository<StockAdjustmentRecord, Long>
interface StockAdjustmentRecordRepository : AbstractRepository<StockAdjustmentRecord, Long> {
@Query(
"""
SELECT r FROM StockAdjustmentRecord r
WHERE r.item.id = :itemId
AND r.deleted = false
AND r.remarks IS NOT NULL
ORDER BY r.id DESC
"""
)
fun findRemarkRecordsByItemId(@Param("itemId") itemId: Long): List<StockAdjustmentRecord>
}

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt Zobrazit soubor

@@ -103,6 +103,8 @@ fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLe
AND (:lotNo IS NULL OR il.lotNo LIKE CONCAT('%', :lotNo, '%')) AND (:lotNo IS NULL OR il.lotNo LIKE CONCAT('%', :lotNo, '%'))
AND (:startDate IS NULL OR il.expiryDate >= :startDate) AND (:startDate IS NULL OR il.expiryDate >= :startDate)
AND (:endDateExclusive IS NULL OR il.expiryDate < :endDateExclusive) AND (:endDateExclusive IS NULL OR il.expiryDate < :endDateExclusive)
AND (:handledStartDate IS NULL OR sl.date >= :handledStartDate)
AND (:handledEndDateExclusive IS NULL OR sl.date < :handledEndDateExclusive)
ORDER BY sl.date DESC, sl.id DESC ORDER BY sl.date DESC, sl.id DESC
""") """)
fun findExpiryItemHandleRecords( fun findExpiryItemHandleRecords(
@@ -111,6 +113,8 @@ fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLe
@Param("lotNo") lotNo: String?, @Param("lotNo") lotNo: String?,
@Param("startDate") startDate: LocalDate?, @Param("startDate") startDate: LocalDate?,
@Param("endDateExclusive") endDateExclusive: LocalDate?, @Param("endDateExclusive") endDateExclusive: LocalDate?,
@Param("handledStartDate") handledStartDate: LocalDate?,
@Param("handledEndDateExclusive") handledEndDateExclusive: LocalDate?,
pageable: Pageable, pageable: Pageable,
): Page<StockLedger> ): Page<StockLedger>
} }

+ 26
- 6
src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt Zobrazit soubor

@@ -4,6 +4,7 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.stock.entity.InventoryLotLine import com.ffii.fpsms.modules.stock.entity.InventoryLotLine
import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository
import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRequest import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRequest
import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRemarksResponse
import com.ffii.fpsms.modules.stock.web.model.StockInRequest import com.ffii.fpsms.modules.stock.web.model.StockInRequest
import com.ffii.fpsms.modules.stock.web.model.StockOutRequest import com.ffii.fpsms.modules.stock.web.model.StockOutRequest
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -24,6 +25,7 @@ open class StockAdjustmentService(
private val stockAdjustmentRecordRepository: StockAdjustmentRecordRepository private val stockAdjustmentRecordRepository: StockAdjustmentRecordRepository
) { ) {


/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */
@Transactional @Transactional
open fun submit(request: StockAdjustmentRequest): MessageResponse { open fun submit(request: StockAdjustmentRequest): MessageResponse {
val originalById = request.originalLines.filter { it.id > 0 }.associateBy { it.id } val originalById = request.originalLines.filter { it.id > 0 }.associateBy { it.id }
@@ -36,7 +38,7 @@ open class StockAdjustmentService(
inventoryLotLineId = line.id, inventoryLotLineId = line.id,
type = "ADJ" type = "ADJ"
) )
saveAdjustmentRecordForStockOut(stockOutLine)
saveAdjustmentRecordForStockOut(stockOutLine, line.remarks)
} }
for (current in request.currentLines) { for (current in request.currentLines) {
// Branch 2: New entry — createStockIn (OPEN or ADJ) // Branch 2: New entry — createStockIn (OPEN or ADJ)
@@ -56,7 +58,7 @@ open class StockAdjustmentService(
warehouseId = current.warehouseId warehouseId = current.warehouseId
) )
) )
saveAdjustmentRecordForStockIn(stockInLine)
saveAdjustmentRecordForStockIn(stockInLine, current.remarks)
continue continue
} }


@@ -74,7 +76,7 @@ open class StockAdjustmentService(
stockInRequest, stockInRequest,
inventoryLotLine inventoryLotLine
) )
saveAdjustmentRecordForStockIn(stockInLine)
saveAdjustmentRecordForStockIn(stockInLine, current.remarks)
} else { } else {
// Branch 3 (qty down): adjustment outbound only (not pick createStockOut) // Branch 3 (qty down): adjustment outbound only (not pick createStockOut)
val stockOutLine = stockOutLineService.createStockOutForAdjustment( val stockOutLine = stockOutLineService.createStockOutForAdjustment(
@@ -84,7 +86,7 @@ open class StockAdjustmentService(
type = "ADJ" type = "ADJ"
) )
) )
saveAdjustmentRecordForStockOut(stockOutLine)
saveAdjustmentRecordForStockOut(stockOutLine, current.remarks)
} }
} }


@@ -98,6 +100,19 @@ open class StockAdjustmentService(
) )
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */
open fun findLatestRemarksByItemId(itemId: Long): List<StockAdjustmentRemarksResponse> {
val seenLotNos = HashSet<String>()
val result = mutableListOf<StockAdjustmentRemarksResponse>()
for (row in stockAdjustmentRecordRepository.findRemarkRecordsByItemId(itemId)) {
val remarks = normalizeRemarks(row.remarks) ?: continue
val lotNo = row.lotNo?.trim().orEmpty()
if (lotNo.isEmpty() || !seenLotNos.add(lotNo)) continue
result.add(StockAdjustmentRemarksResponse(lotNo = lotNo, remarks = remarks))
}
return result
}

private fun buildStockInRequestFromExistingLotLine( private fun buildStockInRequestFromExistingLotLine(
inventoryLotLine: InventoryLotLine, inventoryLotLine: InventoryLotLine,
acceptedQty: BigDecimal acceptedQty: BigDecimal
@@ -131,7 +146,7 @@ open class StockAdjustmentService(
warehouseId = warehouseId warehouseId = warehouseId
) )
} }
private fun saveAdjustmentRecordForStockIn(stockInLine: StockInLine) {
private fun saveAdjustmentRecordForStockIn(stockInLine: StockInLine, remarks: String?) {
val item = stockInLine.item ?: return val item = stockInLine.item ?: return
val record = StockAdjustmentRecord().apply { val record = StockAdjustmentRecord().apply {
this.item = item this.item = item
@@ -140,13 +155,14 @@ open class StockAdjustmentService(
this.lotNo = stockInLine.lotNo this.lotNo = stockInLine.lotNo
this.inQty = stockInLine.acceptedQty this.inQty = stockInLine.acceptedQty
this.outQty = null this.outQty = null
this.remarks = normalizeRemarks(remarks)
this.stockInLine = stockInLine this.stockInLine = stockInLine
this.stockOutLine = null this.stockOutLine = null
} }
stockAdjustmentRecordRepository.save(record) stockAdjustmentRecordRepository.save(record)
} }


private fun saveAdjustmentRecordForStockOut(stockOutLine: StockOutLine) {
private fun saveAdjustmentRecordForStockOut(stockOutLine: StockOutLine, remarks: String?) {
val item = stockOutLine.item ?: return val item = stockOutLine.item ?: return
val lotNo = stockOutLine.inventoryLotLine?.inventoryLot?.lotNo val lotNo = stockOutLine.inventoryLotLine?.inventoryLot?.lotNo
val record = StockAdjustmentRecord().apply { val record = StockAdjustmentRecord().apply {
@@ -156,9 +172,13 @@ open class StockAdjustmentService(
this.lotNo = lotNo this.lotNo = lotNo
this.inQty = null this.inQty = null
this.outQty = BigDecimal.valueOf(stockOutLine.qty ?: 0.0) this.outQty = BigDecimal.valueOf(stockOutLine.qty ?: 0.0)
this.remarks = normalizeRemarks(remarks)
this.stockInLine = null this.stockInLine = null
this.stockOutLine = stockOutLine this.stockOutLine = stockOutLine
} }
stockAdjustmentRecordRepository.save(record) stockAdjustmentRecordRepository.save(record)
} }

private fun normalizeRemarks(remarks: String?): String? =
remarks?.trim()?.takeIf { it.isNotEmpty() }
} }

+ 23
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt Zobrazit soubor

@@ -86,8 +86,20 @@ open class StockIssueService(
return searchHandleRecords(request, "Bad") return searchHandleRecords(request, "Bad")
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
open fun getExpiryItemHandleRecords(request: SearchStockIssueRecordRequest): RecordsRes<StockIssueHandleRecordResponse> { open fun getExpiryItemHandleRecords(request: SearchStockIssueRecordRequest): RecordsRes<StockIssueHandleRecordResponse> {
val (startDate, endDateExclusive) = resolveDateRange(request.startDate, request.endDate)
val hasHandledDateFilter =
request.handledStartDate != null || request.handledEndDate != null
val (startDate, endDateExclusive) =
if (hasHandledDateFilter && request.startDate == null && request.endDate == null) {
Pair(null, null)
} else {
resolveDateRange(request.startDate, request.endDate)
}
val (handledStartDate, handledEndDateExclusive) = resolveOptionalDateRange(
request.handledStartDate,
request.handledEndDate,
)
val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() } val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() }
val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() } val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() }
val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() }
@@ -103,6 +115,8 @@ open class StockIssueService(
lotNo = lotNo, lotNo = lotNo,
startDate = startDate, startDate = startDate,
endDateExclusive = endDateExclusive, endDateExclusive = endDateExclusive,
handledStartDate = handledStartDate,
handledEndDateExclusive = handledEndDateExclusive,
pageable = pageable, pageable = pageable,
) )


@@ -144,6 +158,14 @@ open class StockIssueService(
return Pair(start, endInclusive.plusDays(1)) return Pair(start, endInclusive.plusDays(1))
} }


/** Inclusive start/end; no default window when both are empty. */
private fun resolveOptionalDateRange(startDate: LocalDate?, endDate: LocalDate?): Pair<LocalDate?, LocalDate?> {
if (startDate == null && endDate == null) return Pair(null, null)
val start = startDate ?: endDate
val endInclusive = endDate ?: startDate
return Pair(start, endInclusive?.plusDays(1))
}

private fun toRecordResponse(ledger: StockLedger): StockIssueHandleRecordResponse { private fun toRecordResponse(ledger: StockLedger): StockIssueHandleRecordResponse {
val stockOutLine = ledger.stockOutLine val stockOutLine = ledger.stockOutLine
val lotLine = stockOutLine?.inventoryLotLine val lotLine = stockOutLine?.inventoryLotLine


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/StockAdjustmentController.kt Zobrazit soubor

@@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.stock.web
import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.stock.service.StockAdjustmentService import com.ffii.fpsms.modules.stock.service.StockAdjustmentService
import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRequest import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRequest
import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRemarksResponse
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*


@@ -11,8 +12,15 @@ import org.springframework.web.bind.annotation.*
class StockAdjustmentController( class StockAdjustmentController(
private val stockAdjustmentService: StockAdjustmentService private val stockAdjustmentService: StockAdjustmentService
) { ) {
/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */
@PostMapping("/submit") @PostMapping("/submit")
fun submit(@Valid @RequestBody request: StockAdjustmentRequest): MessageResponse { fun submit(@Valid @RequestBody request: StockAdjustmentRequest): MessageResponse {
return stockAdjustmentService.submit(request) return stockAdjustmentService.submit(request)
} }

/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */
@GetMapping("/latestRemarks")
fun latestRemarks(@RequestParam itemId: Long): List<StockAdjustmentRemarksResponse> {
return stockAdjustmentService.findLatestRemarksByItemId(itemId)
}
} }

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt Zobrazit soubor

@@ -48,6 +48,8 @@ class StockIssueController(
fun getExpiryItemRecords( fun getExpiryItemRecords(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) handledStartDate: LocalDate?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) handledEndDate: LocalDate?,
@RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) itemName: String?, @RequestParam(required = false) itemName: String?,
@RequestParam(required = false) lotNo: String?, @RequestParam(required = false) lotNo: String?,
@@ -58,6 +60,8 @@ class StockIssueController(
SearchStockIssueRecordRequest( SearchStockIssueRecordRequest(
startDate = startDate, startDate = startDate,
endDate = endDate, endDate = endDate,
handledStartDate = handledStartDate,
handledEndDate = handledEndDate,
itemCode = itemCode, itemCode = itemCode,
itemName = itemName, itemName = itemName,
lotNo = lotNo, lotNo = lotNo,


+ 7
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockAdjustmentRequest.kt Zobrazit soubor

@@ -14,11 +14,17 @@ data class StockAdjustmentLineRequest(
val itemNo: String, val itemNo: String,
val expiryDate: String, val expiryDate: String,
val warehouseId: Long, val warehouseId: Long,
val uom: String? = null
val uom: String? = null,
val remarks: String? = null
) )


data class StockAdjustmentRequest( data class StockAdjustmentRequest(
val itemId: Long, val itemId: Long,
val originalLines: List<StockAdjustmentLineRequest>, val originalLines: List<StockAdjustmentLineRequest>,
val currentLines: List<StockAdjustmentLineRequest> val currentLines: List<StockAdjustmentLineRequest>
)

data class StockAdjustmentRemarksResponse(
val lotNo: String?,
val remarks: String
) )

+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt Zobrazit soubor

@@ -13,6 +13,8 @@ data class HandleBadItemRequest(
data class SearchStockIssueRecordRequest( data class SearchStockIssueRecordRequest(
val startDate: LocalDate? = null, val startDate: LocalDate? = null,
val endDate: LocalDate? = null, val endDate: LocalDate? = null,
val handledStartDate: LocalDate? = null,
val handledEndDate: LocalDate? = null,
val itemCode: String? = null, val itemCode: String? = null,
val itemName: String? = null, val itemName: String? = null,
val lotNo: String? = null, val lotNo: String? = null,


+ 5
- 0
src/main/resources/db/changelog/changes/20260907_do1_2300/01_update_do1_schedule_to_23.sql Zobrazit soubor

@@ -0,0 +1,5 @@
--liquibase formatted sql
--changeset fpsms:20260907_do1_schedule_2300

-- Mon–Fri & Sun DO1 → 23:00. Saturday SCHEDULE.m18.do1.sat (03:10) is unchanged.
UPDATE `settings` SET `value` = '0 0 23 * * *' WHERE `name` = 'SCHEDULE.m18.do1';

+ 6
- 0
src/main/resources/db/changelog/changes/20260908_stock_adjustment_record_remarks/01_add_remarks.sql Zobrazit soubor

@@ -0,0 +1,6 @@
--liquibase formatted sql
--changeset fpsms:stock_adjustment_record_remarks
--comment: Store stock adjustment reason/remarks

ALTER TABLE `stock_adjustment_record`
ADD COLUMN `remarks` VARCHAR(500) NULL AFTER `outQty`;

+ 42
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt Zobrazit soubor

@@ -0,0 +1,42 @@
package com.ffii.fpsms.modules.jobOrder.service

import com.ffii.fpsms.modules.jobOrder.entity.JobOrder
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class OnPackExpiryZipStemTest {

@Test
fun expiry_file_stem_keeps_first_as_plain_item_code() {
assertEquals("pp2211", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1, 1))
assertEquals("pp2211", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1, 2))
assertEquals("pp2211-2", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 2, 2))
assertEquals("pp2211-3", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 3, 3))
}

@Test
fun expiry_print_code_shows_dash_one_on_first_bag_when_multiple() {
assertEquals("PP2211", PlasticBagPrinterService.onPackExpiryPrintCode("PP2211", 1, 1))
assertEquals("PP2211-1", PlasticBagPrinterService.onPackExpiryPrintCode("pp2211", 1, 2))
assertEquals("PP2211-2", PlasticBagPrinterService.onPackExpiryPrintCode("PP2211", 2, 2))
assertEquals("PP2211-3", PlasticBagPrinterService.onPackExpiryPrintCode("PP2211", 3, 3))
}

@Test
fun export_job_orders_sort_by_job_order_code_then_id() {
val jo10 = JobOrder().apply { id = 10L; code = "JO-002" }
val jo20 = JobOrder().apply { id = 20L; code = "JO-001" }
val jo30 = JobOrder().apply { id = 30L; code = "JO-001" }
val byId = mapOf(10L to jo10, 20L to jo20, 30L to jo30)
val sorted = PlasticBagPrinterService.sortOnPackExportJobOrders(
listOf(
OnPackQrJobOrderRequest(10, "PP2211"),
OnPackQrJobOrderRequest(30, "PP2211"),
OnPackQrJobOrderRequest(20, "PP2211"),
),
byId,
)
assertEquals(listOf(20L, 30L, 10L), sorted.map { it.jobOrderId })
}
}

Načítá se…
Zrušit
Uložit