| @@ -1814,24 +1814,30 @@ open fun getBadItemOnlyList(): List<PickExecutionIssue> { | |||
| return pickExecutionIssueRepository.findBadItemOnlyList(IssueCategory.lot_issue) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ | |||
| open fun getExpiryItemList( | |||
| expiryDate: LocalDate? = null, | |||
| itemCode: String? = null, | |||
| itemName: String? = null, | |||
| lotNo: String? = null, | |||
| daysAhead: Int? = null, | |||
| ): List<ExpiryItemResponse> { | |||
| val today = LocalDate.now() | |||
| val days = (daysAhead ?: 7).coerceIn(0, 365) | |||
| val untilDate = today.plusDays(days.toLong()) | |||
| val normalizedItemCode = itemCode?.trim()?.takeIf { it.isNotEmpty() } | |||
| val normalizedItemName = itemName?.trim()?.takeIf { it.isNotEmpty() } | |||
| val normalizedLotNo = lotNo?.trim()?.takeIf { it.isNotEmpty() } | |||
| val lotLines = inventoryLotLineRepository.findExpiredItems( | |||
| today = today, | |||
| expiryDate = expiryDate, | |||
| untilDate = untilDate, | |||
| itemCode = normalizedItemCode, | |||
| itemName = normalizedItemName, | |||
| lotNo = normalizedLotNo, | |||
| ) | |||
| return lotLines.map { lotLine -> | |||
| val lot = lotLine.inventoryLot | |||
| val item = lot?.item // Get item from inventoryLot | |||
| val expiry = lot?.expiryDate | |||
| ExpiryItemResponse( | |||
| id = lotLine.id ?: 0L, | |||
| itemId = item?.id ?: 0L, | |||
| @@ -1840,8 +1846,10 @@ open fun getExpiryItemList( | |||
| lotId = lot?.id ?: 0L, | |||
| lotNo = lot?.lotNo, | |||
| storeLocation = lotLine.warehouse?.code, // Construct from warehouse | |||
| expiryDate = lot?.expiryDate, | |||
| remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO) | |||
| expiryDate = expiry, | |||
| remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO), | |||
| uomDesc = lotLine.stockUom?.uom?.udfudesc, | |||
| canHandle = expiry != null && !expiry.isAfter(today), | |||
| ) | |||
| } | |||
| } | |||
| @@ -1993,6 +2001,7 @@ open fun submitBadItem(request: SubmitIssueRequest): MessageResponse { | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse { | |||
| try { | |||
| @@ -2011,7 +2020,7 @@ open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse { | |||
| val lot = lotLine.inventoryLot | |||
| val today = LocalDate.now() | |||
| if (lot?.expiryDate == null || !lot.expiryDate!!.isBefore(today)) { | |||
| if (lot?.expiryDate == null || lot.expiryDate!!.isAfter(today)) { | |||
| return MessageResponse( | |||
| id = null, | |||
| name = "Error", | |||
| @@ -2201,6 +2210,7 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse { | |||
| } | |||
| } | |||
| // Fix batchSubmitExpiryItem method (around line 945): | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse { | |||
| try { | |||
| @@ -2209,8 +2219,8 @@ open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageRespon | |||
| val lot = it.inventoryLot | |||
| val today = LocalDate.now() | |||
| lot?.expiryDate != null && | |||
| lot.expiryDate!!.isBefore(today) && | |||
| (it.inQty ?: BigDecimal.ZERO) != (it.outQty ?: BigDecimal.ZERO) | |||
| lot.expiryDate!!.let { !it.isAfter(today) } && | |||
| (it.inQty ?: BigDecimal.ZERO) > (it.outQty ?: BigDecimal.ZERO) | |||
| } | |||
| if (lotLines.isEmpty()) { | |||
| @@ -6,15 +6,18 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssue | |||
| import com.ffii.fpsms.modules.pickOrder.enums.PickExecutionIssueEnum | |||
| import com.ffii.fpsms.modules.pickOrder.service.PickExecutionIssueService // 修复导入路径 | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.* | |||
| import org.springframework.format.annotation.DateTimeFormat | |||
| import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService | |||
| import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters | |||
| import com.ffii.fpsms.modules.report.web.lotExpiryAlertExcelResponse | |||
| import org.springframework.http.ResponseEntity | |||
| import org.springframework.web.bind.annotation.* | |||
| import java.time.LocalDate | |||
| @RestController | |||
| @RequestMapping("/pickExecution") | |||
| class PickExecutionIssueController( | |||
| private val pickExecutionIssueService: PickExecutionIssueService | |||
| private val pickExecutionIssueService: PickExecutionIssueService, | |||
| private val lotExpiryAlertReportService: LotExpiryAlertReportService, | |||
| ) { | |||
| @PostMapping("/recordIssue") | |||
| @@ -66,19 +69,44 @@ class PickExecutionIssueController( | |||
| return pickExecutionIssueService.getBadItemList(issueCategory) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ | |||
| @GetMapping("/issues/expiryItem") | |||
| fun getExpiryItemIssues( | |||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) expiryDate: LocalDate?, | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false) itemName: String?, | |||
| @RequestParam(required = false) lotNo: String?, | |||
| @RequestParam(required = false) daysAhead: Int?, | |||
| ): List<ExpiryItemResponse> { | |||
| return pickExecutionIssueService.getExpiryItemList( | |||
| expiryDate = expiryDate, | |||
| itemCode = itemCode, | |||
| itemName = itemName, | |||
| lotNo = lotNo, | |||
| daysAhead = daysAhead, | |||
| ) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ | |||
| @GetMapping("/issues/expiryItem/excel") | |||
| fun exportExpiryItemExcel( | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false) itemName: String?, | |||
| @RequestParam(required = false) lotNo: String?, | |||
| @RequestParam(required = false) bucket: String?, | |||
| @RequestParam(required = false) daysAhead: Int?, | |||
| ): ResponseEntity<ByteArray> { | |||
| return lotExpiryAlertExcelResponse( | |||
| lotExpiryAlertReportService.exportExcel( | |||
| SearchFilters( | |||
| itemCode = itemCode, | |||
| itemName = itemName, | |||
| lotNo = lotNo, | |||
| bucket = bucket, | |||
| daysAhead = daysAhead, | |||
| ), | |||
| ), | |||
| ) | |||
| } | |||
| @PostMapping("/submitMissItem") | |||
| fun submitMissItem(@RequestBody request: SubmitIssueRequest): MessageResponse { | |||
| return pickExecutionIssueService.submitMissItem(request) | |||
| @@ -99,11 +127,13 @@ fun batchSubmitBadItem(@RequestBody request: BatchSubmitIssueRequest): MessageRe | |||
| return pickExecutionIssueService.batchSubmitBadItem(request) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ | |||
| @PostMapping("/submitExpiryItem") | |||
| fun submitExpiryItem(@RequestBody request: SubmitExpiryRequest): MessageResponse { | |||
| return pickExecutionIssueService.submitExpiryItem(request) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ | |||
| @PostMapping("/batchSubmitExpiryItem") | |||
| fun batchSubmitExpiryItem(@RequestBody request: BatchSubmitExpiryRequest): MessageResponse { | |||
| return pickExecutionIssueService.batchSubmitExpiryItem(request) | |||
| @@ -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, | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -220,27 +220,29 @@ WHERE ill.id = :id | |||
| @EntityGraph( | |||
| type = EntityGraph.EntityGraphType.FETCH, | |||
| attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"] | |||
| attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse", "stockUom", "stockUom.uom"] | |||
| ) | |||
| @Query(""" | |||
| SELECT ill FROM InventoryLotLine ill | |||
| JOIN ill.inventoryLot il | |||
| JOIN il.item i | |||
| WHERE il.expiryDate < :today | |||
| AND (:expiryDate IS NULL OR il.expiryDate = :expiryDate) | |||
| WHERE il.expiryDate IS NOT NULL | |||
| AND il.expiryDate <= :untilDate | |||
| AND (:itemCode IS NULL OR LOWER(i.code) LIKE LOWER(CONCAT('%', :itemCode, '%'))) | |||
| AND (:itemName IS NULL OR LOWER(i.name) LIKE LOWER(CONCAT('%', :itemName, '%'))) | |||
| AND coalesce(ill.inQty, 0) <> coalesce(ill.outQty, 0) | |||
| AND (:lotNo IS NULL OR LOWER(il.lotNo) LIKE LOWER(CONCAT('%', :lotNo, '%'))) | |||
| AND coalesce(ill.inQty, 0) > coalesce(ill.outQty, 0) | |||
| AND ill.deleted = false | |||
| AND il.deleted = false | |||
| AND i.deleted = false | |||
| ORDER BY il.expiryDate ASC | |||
| """) | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ | |||
| fun findExpiredItems( | |||
| @Param("today") today: LocalDate, | |||
| @Param("expiryDate") expiryDate: LocalDate?, | |||
| @Param("untilDate") untilDate: LocalDate, | |||
| @Param("itemCode") itemCode: String?, | |||
| @Param("itemName") itemName: String?, | |||
| @Param("lotNo") lotNo: String?, | |||
| ): List<InventoryLotLine> | |||
| /** | |||
| @@ -103,6 +103,8 @@ fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLe | |||
| AND (:lotNo IS NULL OR il.lotNo LIKE CONCAT('%', :lotNo, '%')) | |||
| AND (:startDate IS NULL OR il.expiryDate >= :startDate) | |||
| AND (:endDateExclusive IS NULL OR il.expiryDate < :endDateExclusive) | |||
| AND (:handledStartDate IS NULL OR sl.date >= :handledStartDate) | |||
| AND (:handledEndDateExclusive IS NULL OR sl.date < :handledEndDateExclusive) | |||
| ORDER BY sl.date DESC, sl.id DESC | |||
| """) | |||
| fun findExpiryItemHandleRecords( | |||
| @@ -111,6 +113,8 @@ fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLe | |||
| @Param("lotNo") lotNo: String?, | |||
| @Param("startDate") startDate: LocalDate?, | |||
| @Param("endDateExclusive") endDateExclusive: LocalDate?, | |||
| @Param("handledStartDate") handledStartDate: LocalDate?, | |||
| @Param("handledEndDateExclusive") handledEndDateExclusive: LocalDate?, | |||
| pageable: Pageable, | |||
| ): Page<StockLedger> | |||
| } | |||
| @@ -86,8 +86,20 @@ open class StockIssueService( | |||
| return searchHandleRecords(request, "Bad") | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ | |||
| open fun getExpiryItemHandleRecords(request: SearchStockIssueRecordRequest): RecordsRes<StockIssueHandleRecordResponse> { | |||
| val (startDate, endDateExclusive) = resolveDateRange(request.startDate, request.endDate) | |||
| val hasHandledDateFilter = | |||
| request.handledStartDate != null || request.handledEndDate != null | |||
| val (startDate, endDateExclusive) = | |||
| if (hasHandledDateFilter && request.startDate == null && request.endDate == null) { | |||
| Pair(null, null) | |||
| } else { | |||
| resolveDateRange(request.startDate, request.endDate) | |||
| } | |||
| val (handledStartDate, handledEndDateExclusive) = resolveOptionalDateRange( | |||
| request.handledStartDate, | |||
| request.handledEndDate, | |||
| ) | |||
| val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() } | |||
| val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() } | |||
| val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } | |||
| @@ -103,6 +115,8 @@ open class StockIssueService( | |||
| lotNo = lotNo, | |||
| startDate = startDate, | |||
| endDateExclusive = endDateExclusive, | |||
| handledStartDate = handledStartDate, | |||
| handledEndDateExclusive = handledEndDateExclusive, | |||
| pageable = pageable, | |||
| ) | |||
| @@ -144,6 +158,14 @@ open class StockIssueService( | |||
| return Pair(start, endInclusive.plusDays(1)) | |||
| } | |||
| /** Inclusive start/end; no default window when both are empty. */ | |||
| private fun resolveOptionalDateRange(startDate: LocalDate?, endDate: LocalDate?): Pair<LocalDate?, LocalDate?> { | |||
| if (startDate == null && endDate == null) return Pair(null, null) | |||
| val start = startDate ?: endDate | |||
| val endInclusive = endDate ?: startDate | |||
| return Pair(start, endInclusive?.plusDays(1)) | |||
| } | |||
| private fun toRecordResponse(ledger: StockLedger): StockIssueHandleRecordResponse { | |||
| val stockOutLine = ledger.stockOutLine | |||
| val lotLine = stockOutLine?.inventoryLotLine | |||
| @@ -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, | |||
| @@ -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, | |||