瀏覽代碼

Merge branch 'production' into fix負數倉

fix負數倉
CANCERYS\kw093 1 周之前
父節點
當前提交
70e590ec4d
共有 31 個文件被更改,包括 939 次插入157 次删除
  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 查看文件

@@ -46,7 +46,8 @@ public class JdbcDao {
*/
public String queryForString(String sql, Map<String, ?> paramMap) {
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) {
return StringUtils.EMPTY;
}
@@ -60,7 +61,8 @@ public class JdbcDao {
*/
public String queryForString(String sql, Object paramObj) {
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) {
return StringUtils.EMPTY;
}


+ 4
- 2
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java 查看文件

@@ -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. 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
@Order(1)
@@ -119,6 +119,8 @@ public class SecurityConfig {
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.POST, "/stockAdjustment/submit")
.hasAuthority("INVENTORY_ADJUST")
.requestMatchers(HttpMethod.GET, "/stockAdjustment/latestRemarks")
.hasAuthority("INVENTORY_ADJUST")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace")
.hasAuthority("ITEM_TRACING")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**")


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java 查看文件

@@ -25,6 +25,7 @@ public abstract class SettingNames {
*/
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";
/** 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";


+ 2
- 2
src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt 查看文件

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


+ 13
- 9
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt 查看文件

@@ -79,6 +79,11 @@ open class SchedulerService(
@Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean,
) {
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). */
const val DO2_MODIFIED_TO_HOUR: Int = 13
const val DO2_DEFAULT_CRON: String = "0 0 13 * * *"
@@ -186,18 +191,18 @@ open class SchedulerService(
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)) {
cronMain = "0 10 19 * * *"
cronMain = DO1_DEFAULT_CRON
}
val weekdaySunCron = cronToMonFriSunSchedule(cronMain)
val mainCron = if (isValidCronExpression(weekdaySunCron)) weekdaySunCron else cronMain

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)) {
cronSat = "0 10 3 ? * SAT"
cronSat = DO1_SAT_DEFAULT_CRON
}
scheduledM18Do1Sat = taskScheduler.schedule({ getM18Dos1() }, CronTrigger(cronSat))

@@ -824,14 +829,13 @@ open class SchedulerService(
val ysd = today.minusDays(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 modifiedFromStart = if (isSundayDo2) {
ysd.withHour(3).withMinute(0).withSecond(0)
} else {
ysd.withHour(19).withMinute(0).withSecond(0)
ysd.withHour(DO2_MODIFIED_FROM_HOUR).withMinute(0).withSecond(0)
}

val modifiedDateToEnd =
@@ -840,7 +844,7 @@ open class SchedulerService(
logger.info(
"DO2 modifiedDateFrom={} ({}), modifiedDateTo={}",
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),
)



+ 24
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt 查看文件

@@ -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.pickOrder.entity.PickOrderRepository
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 jakarta.persistence.OptimisticLockException
import org.hibernate.StaleObjectStateException
@@ -491,6 +493,7 @@ class DoReleaseCoordinatorService(
return ids
}
}
/** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */
fun startBatchReleaseAsync(ids: List<Long>, userId: Long): MessageResponse {
if (ids.isEmpty()) {
return MessageResponse(
@@ -502,10 +505,13 @@ class DoReleaseCoordinatorService(
val jobId = UUID.randomUUID().toString()
val status = BatchReleaseJobStatus(jobId = jobId, total = ids.size)
jobs[jobId] = status
val workerSecurityContext = copySecurityContextForWorker()

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

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 {
val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId)
?: return MessageResponse(
@@ -858,8 +868,11 @@ class DoReleaseCoordinatorService(
message = "Delivery Order not found", errorPosition = null, entity = null
)
val workerSecurityContext = copySecurityContextForWorker()

executor.submit {
try {
SecurityContextHolder.setContext(workerSecurityContext)
println("📦 Starting single release for DO $doId")
// 调用 releaseDeliveryOrderWithoutTicket 创建 pick order
@@ -958,6 +971,8 @@ class DoReleaseCoordinatorService(
} catch (e: Exception) {
println("❌ Single release exception: ${e.message}")
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() {
try {
// 1. 查找所有 TEMP- 开头的 single release type 订单


+ 118
- 65
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt 查看文件

@@ -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.stock.service.StockOutLineWorkbenchService
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.transaction.annotation.Transactional
import java.time.Instant
@@ -95,6 +98,7 @@ open class DoWorkbenchReleaseService(
private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService,
private val stockOutLineWorkbenchService: StockOutLineWorkbenchService,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
private val userRepository: UserRepository,
) {
private val poolSize = Runtime.getRuntime().availableProcessors()
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 =
startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "single")

/** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */
private fun startBatchReleaseAsyncInternal(
ids: List<Long>,
userId: Long,
@@ -164,75 +169,83 @@ open class DoWorkbenchReleaseService(
val jobId = UUID.randomUUID().toString()
val status = WorkbenchBatchReleaseJobStatus(jobId = jobId, total = ids.size)
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 {
batchReleaseConcurrencyGate.acquireUninterruptibly()
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) {
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 {
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 successResults = mutableListOf<ReleaseDoResult>()
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)
if (!useV2) {
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 =
if (isDefaultTruckLane) {
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(
"""
UPDATE fpsmsdb.delivery_order_pick_order
@@ -849,7 +894,7 @@ open class DoWorkbenchReleaseService(
mapOf(
"headerId" to headerId,
"modified" to LocalDateTime.now(),
"modifiedBy" to "system",
"modifiedBy" to actor,
),
)
}
@@ -914,10 +959,11 @@ open class DoWorkbenchReleaseService(
isSingleRelease: Boolean,
requiredDate: LocalDate,
ticketFloorSegment: String,
actor: String,
): Long? {
val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment)
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>) {
@@ -941,6 +987,7 @@ open class DoWorkbenchReleaseService(
storeId: String?,
releaseTypeCol: String,
ticketNo: String,
actor: String,
): Long? {
val now = LocalDateTime.now()
jdbcDao.executeUpdate(
@@ -970,9 +1017,9 @@ open class DoWorkbenchReleaseService(
"ticketNo" to ticketNo,
"releaseType" to releaseTypeCol,
"created" to now,
"createdBy" to "system",
"createdBy" to actor,
"modified" to now,
"modifiedBy" to "system",
"modifiedBy" to actor,
)
)
return jdbcDao.queryForList(
@@ -984,7 +1031,7 @@ open class DoWorkbenchReleaseService(
LIMIT 1
""".trimIndent(),
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,
requiredDate: LocalDate,
ticketFloorSegment: String,
actor: String,
): Int {
val activeMerge = findActiveMergeTicket(first, storeId, isSingleRelease)
if (activeMerge != null) {
@@ -1010,14 +1058,14 @@ open class DoWorkbenchReleaseService(
val sourceToRetire = plainBatch ?: legacyUpgraded

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

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

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

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

@@ -1473,6 +1525,7 @@ open class DoWorkbenchReleaseService(
isSingleRelease: Boolean,
requiredDate: LocalDate,
ticketFloorSegment: String,
actor: String,
): Long? {
val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment)
val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease)
@@ -1504,9 +1557,9 @@ open class DoWorkbenchReleaseService(
"ticketNo" to ticketNo,
"releaseType" to releaseType,
"created" to now,
"createdBy" to "system",
"createdBy" to actor,
"modified" to now,
"modifiedBy" to "system",
"modifiedBy" to actor,
),
)
return jdbcDao.queryForList(
@@ -1515,6 +1568,6 @@ open class DoWorkbenchReleaseService(
WHERE ticketNo = :ticketNo ORDER BY id DESC LIMIT 1
""".trimIndent(),
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 查看文件

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


+ 91
- 34
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt 查看文件

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

private data class OnPackBmpExportItem(
val codeLower: String,
/** ZIP / template file stem. First JO stays `pp2211` so operators select that template. */
val fileStem: String,
val itemId: Long,
val stockInLineId: Long,
val itemCode: String,
/** Code BMP text. When that item has 2+ JOs, first bag shows `PP2211-1`. */
val printItemCode: String,
val productName: String,
val planDate: LocalDate?,
)
@@ -149,6 +153,24 @@ class PlasticBagPrinterService(
fun laserAckLooksInvalid(ack: String?): Boolean =
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`. */
fun formatLaserExpiryParam(expiryDate: String?): String {
val raw = expiryDate?.trim().orEmpty()
@@ -285,6 +307,37 @@ class PlasticBagPrinterService(
.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.
*/
@@ -725,30 +778,34 @@ class PlasticBagPrinterService(
emptyMap()
}

val jobOrdersById = jobOrderRepository.findAllById(
packagingJobOrders.map { it.jobOrderId }.distinct(),
).associateBy { it.id!! }
val exportItemsRaw = packagingJobOrders
.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" }
@@ -808,12 +865,12 @@ class PlasticBagPrinterService(
ZipOutputStream(baos).use { zos ->
val addedEntries = linkedSetOf<String>()
exportItems.forEach { item ->
val codeLower = item.codeLower
val fileStem = item.fileStem
val imageTemplate = if (includeExpiry) {
val master = expiryMasterImage ?: return@forEach
OnPackPp1181Master.rewriteImageBytes(master, codeLower)
OnPackPp1181Master.rewriteImageBytes(master, fileStem)
} else {
loadOnPackImageTemplateOrNull(codeLower, forExpiry = false) ?: return@forEach
loadOnPackImageTemplateOrNull(item.codeLower, forExpiry = false) ?: return@forEach
}

val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}"""
@@ -821,20 +878,20 @@ class PlasticBagPrinterService(
// Width = 386 + (42 * 2) = 470
// Height = 386 + (1 * 2) = 388 (~389)
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)
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(
item.productName,
ONPACK_PRODUCT_BMP_WIDTH,
ONPACK_PRODUCT_BMP_HEIGHT,
)
val codeBmp = createMonochromeBitmapFixed(
item.itemCode,
item.printItemCode,
ONPACK_CODE_BMP_WIDTH,
ONPACK_CODE_BMP_HEIGHT,
)
@@ -857,7 +914,7 @@ class PlasticBagPrinterService(
}
}
val expiryLabel = if (includeExpiry) {
itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate)
itemDefaultShelfLifeService.expiryDatePrintLabel(item.itemCode, effectivePrintDate)
} else {
null
}
@@ -867,7 +924,7 @@ class PlasticBagPrinterService(
ONPACK_EXPIRY_BMP_WIDTH,
ONPACK_EXPIRY_BMP_HEIGHT,
)
val expBmpFileName = "${codeLower}exp.bmp"
val expBmpFileName = "${fileStem}exp.bmp"
imageContent = withOnPackExpiryLogo(imageContent, expBmpFileName, expBmp.width)
if (addedEntries.add(expBmpFileName)) {
addToZip(zos, expBmpFileName, expBmp.bytes)
@@ -893,7 +950,7 @@ class PlasticBagPrinterService(
addToZip(zos, bmpName, bmpBytes)
}
}
val jobFileName = "${codeLower}.job"
val jobFileName = "$fileStem.job"
if (includeExpiry) {
val masterJob = expiryMasterJob
if (masterJob != null && addedEntries.add(jobFileName)) {


+ 18
- 8
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt 查看文件

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

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
open fun getExpiryItemList(
expiryDate: LocalDate? = null,
itemCode: String? = null,
itemName: String? = null,
lotNo: String? = null,
daysAhead: Int? = null,
): List<ExpiryItemResponse> {
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 normalizedItemName = itemName?.trim()?.takeIf { it.isNotEmpty() }
val normalizedLotNo = lotNo?.trim()?.takeIf { it.isNotEmpty() }
val lotLines = inventoryLotLineRepository.findExpiredItems(
today = today,
expiryDate = expiryDate,
untilDate = untilDate,
itemCode = normalizedItemCode,
itemName = normalizedItemName,
lotNo = normalizedLotNo,
)
return lotLines.map { lotLine ->
val lot = lotLine.inventoryLot
val item = lot?.item // Get item from inventoryLot
val expiry = lot?.expiryDate
ExpiryItemResponse(
id = lotLine.id ?: 0L,
itemId = item?.id ?: 0L,
@@ -1840,8 +1846,10 @@ open fun getExpiryItemList(
lotId = lot?.id ?: 0L,
lotNo = lot?.lotNo,
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])
open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
try {
@@ -2011,7 +2020,7 @@ open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
val lot = lotLine.inventoryLot
val today = LocalDate.now()
if (lot?.expiryDate == null || !lot.expiryDate!!.isBefore(today)) {
if (lot?.expiryDate == null || lot.expiryDate!!.isAfter(today)) {
return MessageResponse(
id = null,
name = "Error",
@@ -2201,6 +2210,7 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse {
}
}
// Fix batchSubmitExpiryItem method (around line 945):
/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@Transactional(rollbackFor = [Exception::class])
open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse {
try {
@@ -2209,8 +2219,8 @@ open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageRespon
val lot = it.inventoryLot
val today = LocalDate.now()
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()) {


+ 35
- 5
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt 查看文件

@@ -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.service.PickExecutionIssueService // 修复导入路径
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 java.time.LocalDate


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

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

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@GetMapping("/issues/expiryItem")
fun getExpiryItemIssues(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) expiryDate: LocalDate?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) itemName: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) daysAhead: Int?,
): List<ExpiryItemResponse> {
return pickExecutionIssueService.getExpiryItemList(
expiryDate = expiryDate,
itemCode = itemCode,
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")
fun submitMissItem(@RequestBody request: SubmitIssueRequest): MessageResponse {
return pickExecutionIssueService.submitMissItem(request)
@@ -99,11 +127,13 @@ fun batchSubmitBadItem(@RequestBody request: BatchSubmitIssueRequest): MessageRe
return pickExecutionIssueService.batchSubmitBadItem(request)
}

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

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


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt 查看文件

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


+ 247
- 0
src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -9,6 +9,8 @@ import java.time.format.DateTimeFormatter
* 庫存批次現況(Stock Balance):永遠今天。
* 現存讀 [inventory_lot_line](available、未過期、in-out > 0)。
* 最後異動:有 inventoryLotLineId 的帳本用 MAX(id);缺的比 SIL/SOL 時間。
* 單位均價:貨品 AverageUnitPrice(HKD/stock UOM)用 item_uom 比率轉成該 lot 單位;
* 同一貨品同一 UOM 共用同一均價。庫存總價值 = 該 UOM 均價 × lot 數量。
* 單位均價/庫存總價值只填 root PO 為 PP/PF 的批(TRF 往回走);其他來源空白。
*/
@Service
@@ -70,6 +72,19 @@ lot_root_origin AS (
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 = """
LEFT JOIN lot_root_origin lro
ON lro.lotId = il.id
@@ -85,7 +100,7 @@ lot_root_origin AS (
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(
itemCode: String?,
storeId: String?,
@@ -133,12 +148,13 @@ lot_root_origin AS (
}
val lotOriginFilterSql = buildLotOriginFilterSql(lotOrigin)
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(
"""
${ctePrefix}SELECT
ill.id AS inventoryLotLineId,
COALESCE(NULLIF(TRIM(ill.remarks), ''), '') AS remarks,
COALESCE(it.code, '') AS itemNo,
COALESCE(it.name, '') AS itemName,
COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure,
@@ -149,7 +165,19 @@ lot_root_origin AS (
COALESCE(wh.area, '') AS areaPart,
COALESCE(wh.slot, '') AS slotPart,
(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
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
@@ -162,6 +190,8 @@ lot_root_origin AS (
ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON uc.id = iu.uomId
LEFT JOIN stock_one so
ON so.itemId = it.id
$originJoinSql
WHERE ill.deleted = 0
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)

/** 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>>) {
if (lotIds.isEmpty()) return
// 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 {
var n = 0
for (chunk in missing.chunked(800)) {
@@ -259,7 +289,7 @@ lot_root_origin AS (
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 {
var n = 0
for (chunk in missing.chunked(800)) {
@@ -281,7 +311,7 @@ lot_root_origin AS (
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 {
var n = 0
for (chunk in missing.chunked(800)) {
@@ -325,7 +355,7 @@ lot_root_origin AS (
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(
liveLots: List<Map<String, Any>>,
lastTrnByLot: Map<Long, Pair<String, String>>,
@@ -344,8 +374,9 @@ lot_root_origin AS (
val last = lastTrnByLot[lotId]
val origin = r["rootPoPrefix"]?.toString().orEmpty().uppercase()
val showPrice = origin == "PP" || origin == "PF"
val row = HashMap<String, Any>(22)
val row = HashMap<String, Any>(24)
row["inventoryLotLineId"] = lotId.toString()
row["remarks"] = r["remarks"] ?: ""
row["itemNo"] = r["itemNo"] ?: ""
row["itemName"] = r["itemName"] ?: ""
row["unitOfMeasure"] = r["unitOfMeasure"] ?: ""


+ 54
- 0
src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt 查看文件

@@ -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 查看文件

@@ -571,11 +571,12 @@ class ReportController(
numberStyle: XSSFCellStyle,
dashStyle: XSSFCellStyle,
preferInt: Boolean = false,
emptyAsBlank: Boolean = false,
) {
val cell = row.createCell(col)
val parsed = parseSignedNumber(value)
if (parsed == null) {
cell.setCellValue("-")
cell.setCellValue(if (emptyAsBlank) "" else "-")
cell.cellStyle = dashStyle
return
}
@@ -962,8 +963,8 @@ class ReportController(
setNumberCellFromFormatted(r, 7, m["totalVariance"], 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, 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 查看文件

@@ -43,7 +43,7 @@ class StockLotOnhandReportController(
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")
fun exportExcel(
@RequestParam(required = false) itemCode: String?,
@@ -191,6 +191,7 @@ class StockLotOnhandReportController(
return v
}

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


+ 8
- 6
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt 查看文件

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

@EntityGraph(
type = EntityGraph.EntityGraphType.FETCH,
attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"]
attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse", "stockUom", "stockUom.uom"]
)
@Query("""
SELECT ill FROM InventoryLotLine ill
JOIN ill.inventoryLot il
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 (: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 il.deleted = false
AND i.deleted = false
ORDER BY il.expiryDate ASC
""")
/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
fun findExpiredItems(
@Param("today") today: LocalDate,
@Param("expiryDate") expiryDate: LocalDate?,
@Param("untilDate") untilDate: LocalDate,
@Param("itemCode") itemCode: String?,
@Param("itemName") itemName: String?,
@Param("lotNo") lotNo: String?,
): List<InventoryLotLine>

/**


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecord.kt 查看文件

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

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

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


+ 14
- 1
src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecordRepository.kt 查看文件

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

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

@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 查看文件

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

+ 26
- 6
src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt 查看文件

@@ -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.InventoryLotLineRepository
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.StockOutRequest
import org.springframework.stereotype.Service
@@ -24,6 +25,7 @@ open class StockAdjustmentService(
private val stockAdjustmentRecordRepository: StockAdjustmentRecordRepository
) {

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

@@ -74,7 +76,7 @@ open class StockAdjustmentService(
stockInRequest,
inventoryLotLine
)
saveAdjustmentRecordForStockIn(stockInLine)
saveAdjustmentRecordForStockIn(stockInLine, current.remarks)
} else {
// Branch 3 (qty down): adjustment outbound only (not pick createStockOut)
val stockOutLine = stockOutLineService.createStockOutForAdjustment(
@@ -84,7 +86,7 @@ open class StockAdjustmentService(
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(
inventoryLotLine: InventoryLotLine,
acceptedQty: BigDecimal
@@ -131,7 +146,7 @@ open class StockAdjustmentService(
warehouseId = warehouseId
)
}
private fun saveAdjustmentRecordForStockIn(stockInLine: StockInLine) {
private fun saveAdjustmentRecordForStockIn(stockInLine: StockInLine, remarks: String?) {
val item = stockInLine.item ?: return
val record = StockAdjustmentRecord().apply {
this.item = item
@@ -140,13 +155,14 @@ open class StockAdjustmentService(
this.lotNo = stockInLine.lotNo
this.inQty = stockInLine.acceptedQty
this.outQty = null
this.remarks = normalizeRemarks(remarks)
this.stockInLine = stockInLine
this.stockOutLine = null
}
stockAdjustmentRecordRepository.save(record)
}

private fun saveAdjustmentRecordForStockOut(stockOutLine: StockOutLine) {
private fun saveAdjustmentRecordForStockOut(stockOutLine: StockOutLine, remarks: String?) {
val item = stockOutLine.item ?: return
val lotNo = stockOutLine.inventoryLotLine?.inventoryLot?.lotNo
val record = StockAdjustmentRecord().apply {
@@ -156,9 +172,13 @@ open class StockAdjustmentService(
this.lotNo = lotNo
this.inQty = null
this.outQty = BigDecimal.valueOf(stockOutLine.qty ?: 0.0)
this.remarks = normalizeRemarks(remarks)
this.stockInLine = null
this.stockOutLine = stockOutLine
}
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 查看文件

@@ -86,8 +86,20 @@ open class StockIssueService(
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> {
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 itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() }
val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() }
@@ -103,6 +115,8 @@ open class StockIssueService(
lotNo = lotNo,
startDate = startDate,
endDateExclusive = endDateExclusive,
handledStartDate = handledStartDate,
handledEndDateExclusive = handledEndDateExclusive,
pageable = pageable,
)

@@ -144,6 +158,14 @@ open class StockIssueService(
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 {
val stockOutLine = ledger.stockOutLine
val lotLine = stockOutLine?.inventoryLotLine


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/StockAdjustmentController.kt 查看文件

@@ -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.stock.service.StockAdjustmentService
import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRequest
import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRemarksResponse
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.*

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

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


+ 7
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockAdjustmentRequest.kt 查看文件

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

data class StockAdjustmentRequest(
val itemId: Long,
val originalLines: 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 查看文件

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


+ 5
- 0
src/main/resources/db/changelog/changes/20260907_do1_2300/01_update_do1_schedule_to_23.sql 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 })
}
}

Loading…
取消
儲存