| @@ -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; | ||||
| } | } | ||||
| @@ -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/**") | ||||
| @@ -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"; | ||||
| @@ -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) | ||||
| } | } | ||||
| @@ -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), | ||||
| ) | ) | ||||
| @@ -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 订单 | ||||
| @@ -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) } | |||||
| } | } | ||||
| } | } | ||||
| @@ -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 | ||||
| } | } | ||||
| @@ -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)) { | ||||
| @@ -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()) { | ||||
| @@ -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) | ||||
| @@ -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, | ||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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"] ?: "" | ||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | } | ||||
| } | } | ||||
| @@ -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, | ||||
| @@ -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> | ||||
| /** | /** | ||||
| @@ -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") | ||||
| @@ -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> | |||||
| } | |||||
| @@ -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> | ||||
| } | } | ||||
| @@ -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() } | |||||
| } | } | ||||
| @@ -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 | ||||
| @@ -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) | |||||
| } | |||||
| } | } | ||||
| @@ -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, | ||||
| @@ -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 | |||||
| ) | ) | ||||
| @@ -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, | ||||
| @@ -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'; | |||||
| @@ -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`; | |||||
| @@ -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 }) | |||||
| } | |||||
| } | |||||