diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index f165f96..5fd8fcb 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -1814,24 +1814,30 @@ open fun getBadItemOnlyList(): List { return pickExecutionIssueRepository.findBadItemOnlyList(IssueCategory.lot_issue) } +/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ open fun getExpiryItemList( - expiryDate: LocalDate? = null, itemCode: String? = null, itemName: String? = null, + lotNo: String? = null, + daysAhead: Int? = null, ): List { val today = LocalDate.now() + val days = (daysAhead ?: 7).coerceIn(0, 365) + val untilDate = today.plusDays(days.toLong()) val normalizedItemCode = itemCode?.trim()?.takeIf { it.isNotEmpty() } val normalizedItemName = itemName?.trim()?.takeIf { it.isNotEmpty() } + val normalizedLotNo = lotNo?.trim()?.takeIf { it.isNotEmpty() } val lotLines = inventoryLotLineRepository.findExpiredItems( - today = today, - expiryDate = expiryDate, + untilDate = untilDate, itemCode = normalizedItemCode, itemName = normalizedItemName, + lotNo = normalizedLotNo, ) return lotLines.map { lotLine -> val lot = lotLine.inventoryLot val item = lot?.item // Get item from inventoryLot + val expiry = lot?.expiryDate ExpiryItemResponse( id = lotLine.id ?: 0L, itemId = item?.id ?: 0L, @@ -1840,8 +1846,10 @@ open fun getExpiryItemList( lotId = lot?.id ?: 0L, lotNo = lot?.lotNo, storeLocation = lotLine.warehouse?.code, // Construct from warehouse - expiryDate = lot?.expiryDate, - remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO) + expiryDate = expiry, + remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO), + uomDesc = lotLine.stockUom?.uom?.udfudesc, + canHandle = expiry != null && !expiry.isAfter(today), ) } } @@ -1993,6 +2001,7 @@ open fun submitBadItem(request: SubmitIssueRequest): MessageResponse { } } +/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ @Transactional(rollbackFor = [Exception::class]) open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse { try { @@ -2011,7 +2020,7 @@ open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse { val lot = lotLine.inventoryLot val today = LocalDate.now() - if (lot?.expiryDate == null || !lot.expiryDate!!.isBefore(today)) { + if (lot?.expiryDate == null || lot.expiryDate!!.isAfter(today)) { return MessageResponse( id = null, name = "Error", @@ -2201,6 +2210,7 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse { } } // Fix batchSubmitExpiryItem method (around line 945): +/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ @Transactional(rollbackFor = [Exception::class]) open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse { try { @@ -2209,8 +2219,8 @@ open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageRespon val lot = it.inventoryLot val today = LocalDate.now() lot?.expiryDate != null && - lot.expiryDate!!.isBefore(today) && - (it.inQty ?: BigDecimal.ZERO) != (it.outQty ?: BigDecimal.ZERO) + lot.expiryDate!!.let { !it.isAfter(today) } && + (it.inQty ?: BigDecimal.ZERO) > (it.outQty ?: BigDecimal.ZERO) } if (lotLines.isEmpty()) { diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt index 3447764..956b13d 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt @@ -6,15 +6,18 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssue import com.ffii.fpsms.modules.pickOrder.enums.PickExecutionIssueEnum import com.ffii.fpsms.modules.pickOrder.service.PickExecutionIssueService // 修复导入路径 import com.ffii.fpsms.modules.pickOrder.web.models.* -import org.springframework.format.annotation.DateTimeFormat +import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService +import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters +import com.ffii.fpsms.modules.report.web.lotExpiryAlertExcelResponse +import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* -import java.time.LocalDate @RestController @RequestMapping("/pickExecution") class PickExecutionIssueController( - private val pickExecutionIssueService: PickExecutionIssueService + private val pickExecutionIssueService: PickExecutionIssueService, + private val lotExpiryAlertReportService: LotExpiryAlertReportService, ) { @PostMapping("/recordIssue") @@ -66,19 +69,44 @@ class PickExecutionIssueController( return pickExecutionIssueService.getBadItemList(issueCategory) } + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ @GetMapping("/issues/expiryItem") fun getExpiryItemIssues( - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) expiryDate: LocalDate?, @RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemName: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) daysAhead: Int?, ): List { return pickExecutionIssueService.getExpiryItemList( - expiryDate = expiryDate, itemCode = itemCode, itemName = itemName, + lotNo = lotNo, + daysAhead = daysAhead, ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ + @GetMapping("/issues/expiryItem/excel") + fun exportExpiryItemExcel( + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) itemName: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) bucket: String?, + @RequestParam(required = false) daysAhead: Int?, + ): ResponseEntity { + return lotExpiryAlertExcelResponse( + lotExpiryAlertReportService.exportExcel( + SearchFilters( + itemCode = itemCode, + itemName = itemName, + lotNo = lotNo, + bucket = bucket, + daysAhead = daysAhead, + ), + ), + ) + } + @PostMapping("/submitMissItem") fun submitMissItem(@RequestBody request: SubmitIssueRequest): MessageResponse { return pickExecutionIssueService.submitMissItem(request) @@ -99,11 +127,13 @@ fun batchSubmitBadItem(@RequestBody request: BatchSubmitIssueRequest): MessageRe return pickExecutionIssueService.batchSubmitBadItem(request) } + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ @PostMapping("/submitExpiryItem") fun submitExpiryItem(@RequestBody request: SubmitExpiryRequest): MessageResponse { return pickExecutionIssueService.submitExpiryItem(request) } + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ @PostMapping("/batchSubmitExpiryItem") fun batchSubmitExpiryItem(@RequestBody request: BatchSubmitExpiryRequest): MessageResponse { return pickExecutionIssueService.batchSubmitExpiryItem(request) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt index d1f67f4..049dd15 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt @@ -31,6 +31,9 @@ data class ExpiryItemResponse( val storeLocation: String?, val expiryDate: LocalDate?, val remainingQty: BigDecimal, + val uomDesc: String?, + /** True when expiryDate is today or earlier. */ + val canHandle: Boolean, ) data class LotIssueDetailRequest( val lotId: Long, diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt b/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt new file mode 100644 index 0000000..7159446 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt @@ -0,0 +1,247 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.CellStyle +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.streaming.SXSSFWorkbook +import java.io.ByteArrayOutputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +internal object LotExpiryAlertExcelBuilder { + private data class ExcelStyles( + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val number: CellStyle, + ) + + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ + fun build( + dbData: List>, + 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 + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertReportService.kt new file mode 100644 index 0000000..b388c42 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertReportService.kt @@ -0,0 +1,126 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service + +@Service +open class LotExpiryAlertReportService( + private val jdbcDao: JdbcDao, +) { + data class SearchFilters( + val itemCode: String? = null, + val itemName: String? = null, + val lotNo: String? = null, + val bucket: String? = null, + val daysAhead: Int? = null, + ) { + fun resolvedDaysAhead(): Int { + val n = daysAhead ?: DEFAULT_DAYS_AHEAD + return n.coerceIn(0, MAX_DAYS_AHEAD) + } + + companion object { + const val DEFAULT_DAYS_AHEAD = 7 + const val MAX_DAYS_AHEAD = 365 + } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ + fun search( + itemCode: String? = null, + itemName: String? = null, + lotNo: String? = null, + bucket: String? = null, + daysAhead: Int? = null, + ): List> = 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> { + val args = mutableMapOf() + 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> + } + + /** 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) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt new file mode 100644 index 0000000..bcf7d29 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt @@ -0,0 +1,54 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService +import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +internal fun lotExpiryAlertExcelResponse(bytes: ByteArray): ResponseEntity { + 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 { + val excelBytes = lotExpiryAlertReportService.exportExcel( + SearchFilters( + itemCode = itemCode, + itemName = itemName, + lotNo = lotNo, + bucket = bucket, + daysAhead = daysAhead, + ), + ) + return lotExpiryAlertExcelResponse(excelBytes) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 630b6d6..7b5ff3e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -220,27 +220,29 @@ WHERE ill.id = :id @EntityGraph( type = EntityGraph.EntityGraphType.FETCH, - attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"] + attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse", "stockUom", "stockUom.uom"] ) @Query(""" SELECT ill FROM InventoryLotLine ill JOIN ill.inventoryLot il JOIN il.item i - WHERE il.expiryDate < :today - AND (:expiryDate IS NULL OR il.expiryDate = :expiryDate) + WHERE il.expiryDate IS NOT NULL + AND il.expiryDate <= :untilDate AND (:itemCode IS NULL OR LOWER(i.code) LIKE LOWER(CONCAT('%', :itemCode, '%'))) AND (:itemName IS NULL OR LOWER(i.name) LIKE LOWER(CONCAT('%', :itemName, '%'))) - AND coalesce(ill.inQty, 0) <> coalesce(ill.outQty, 0) + AND (:lotNo IS NULL OR LOWER(il.lotNo) LIKE LOWER(CONCAT('%', :lotNo, '%'))) + AND coalesce(ill.inQty, 0) > coalesce(ill.outQty, 0) AND ill.deleted = false AND il.deleted = false AND i.deleted = false ORDER BY il.expiryDate ASC """) + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ fun findExpiredItems( - @Param("today") today: LocalDate, - @Param("expiryDate") expiryDate: LocalDate?, + @Param("untilDate") untilDate: LocalDate, @Param("itemCode") itemCode: String?, @Param("itemName") itemName: String?, + @Param("lotNo") lotNo: String?, ): List /** diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt index 1f4a276..f5b3027 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt @@ -103,6 +103,8 @@ fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLe AND (:lotNo IS NULL OR il.lotNo LIKE CONCAT('%', :lotNo, '%')) AND (:startDate IS NULL OR il.expiryDate >= :startDate) AND (:endDateExclusive IS NULL OR il.expiryDate < :endDateExclusive) + AND (:handledStartDate IS NULL OR sl.date >= :handledStartDate) + AND (:handledEndDateExclusive IS NULL OR sl.date < :handledEndDateExclusive) ORDER BY sl.date DESC, sl.id DESC """) fun findExpiryItemHandleRecords( @@ -111,6 +113,8 @@ fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLe @Param("lotNo") lotNo: String?, @Param("startDate") startDate: LocalDate?, @Param("endDateExclusive") endDateExclusive: LocalDate?, + @Param("handledStartDate") handledStartDate: LocalDate?, + @Param("handledEndDateExclusive") handledEndDateExclusive: LocalDate?, pageable: Pageable, ): Page } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt index bceb4dc..1ba9e02 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt @@ -86,8 +86,20 @@ open class StockIssueService( return searchHandleRecords(request, "Bad") } + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ open fun getExpiryItemHandleRecords(request: SearchStockIssueRecordRequest): RecordsRes { - val (startDate, endDateExclusive) = resolveDateRange(request.startDate, request.endDate) + val hasHandledDateFilter = + request.handledStartDate != null || request.handledEndDate != null + val (startDate, endDateExclusive) = + if (hasHandledDateFilter && request.startDate == null && request.endDate == null) { + Pair(null, null) + } else { + resolveDateRange(request.startDate, request.endDate) + } + val (handledStartDate, handledEndDateExclusive) = resolveOptionalDateRange( + request.handledStartDate, + request.handledEndDate, + ) val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() } val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() } val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } @@ -103,6 +115,8 @@ open class StockIssueService( lotNo = lotNo, startDate = startDate, endDateExclusive = endDateExclusive, + handledStartDate = handledStartDate, + handledEndDateExclusive = handledEndDateExclusive, pageable = pageable, ) @@ -144,6 +158,14 @@ open class StockIssueService( return Pair(start, endInclusive.plusDays(1)) } + /** Inclusive start/end; no default window when both are empty. */ + private fun resolveOptionalDateRange(startDate: LocalDate?, endDate: LocalDate?): Pair { + if (startDate == null && endDate == null) return Pair(null, null) + val start = startDate ?: endDate + val endInclusive = endDate ?: startDate + return Pair(start, endInclusive?.plusDays(1)) + } + private fun toRecordResponse(ledger: StockLedger): StockIssueHandleRecordResponse { val stockOutLine = ledger.stockOutLine val lotLine = stockOutLine?.inventoryLotLine diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt index 33425fb..795123f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt @@ -48,6 +48,8 @@ class StockIssueController( fun getExpiryItemRecords( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) handledStartDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) handledEndDate: LocalDate?, @RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemName: String?, @RequestParam(required = false) lotNo: String?, @@ -58,6 +60,8 @@ class StockIssueController( SearchStockIssueRecordRequest( startDate = startDate, endDate = endDate, + handledStartDate = handledStartDate, + handledEndDate = handledEndDate, itemCode = itemCode, itemName = itemName, lotNo = lotNo, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt index 91d7930..b8430bf 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt @@ -13,6 +13,8 @@ data class HandleBadItemRequest( data class SearchStockIssueRecordRequest( val startDate: LocalDate? = null, val endDate: LocalDate? = null, + val handledStartDate: LocalDate? = null, + val handledEndDate: LocalDate? = null, val itemCode: String? = null, val itemName: String? = null, val lotNo: String? = null,