| @@ -1814,14 +1814,16 @@ 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.0 | 2026-09-07 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ | |||||
| open fun getExpiryItemList( | open fun getExpiryItemList( | ||||
| itemCode: String? = null, | itemCode: String? = null, | ||||
| itemName: String? = null, | itemName: String? = null, | ||||
| lotNo: String? = null, | lotNo: String? = null, | ||||
| daysAhead: Int? = null, | |||||
| ): List<ExpiryItemResponse> { | ): List<ExpiryItemResponse> { | ||||
| val today = LocalDate.now() | val today = LocalDate.now() | ||||
| val untilDate = today.plusDays(7) | |||||
| 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 normalizedLotNo = lotNo?.trim()?.takeIf { it.isNotEmpty() } | ||||
| @@ -1999,7 +2001,7 @@ open fun submitBadItem(request: SubmitIssueRequest): MessageResponse { | |||||
| } | } | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */ | |||||
| /** 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 { | ||||
| @@ -2208,7 +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.0 | 2026-09-07 */ | |||||
| /** 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 { | ||||
| @@ -6,13 +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 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.* | ||||
| @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") | ||||
| @@ -64,20 +69,44 @@ class PickExecutionIssueController( | |||||
| return pickExecutionIssueService.getBadItemList(issueCategory) | return pickExecutionIssueService.getBadItemList(issueCategory) | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */ | |||||
| /** 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) 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?, | ||||
| @RequestParam(required = false) daysAhead: Int?, | |||||
| ): List<ExpiryItemResponse> { | ): List<ExpiryItemResponse> { | ||||
| return pickExecutionIssueService.getExpiryItemList( | return pickExecutionIssueService.getExpiryItemList( | ||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| itemName = itemName, | itemName = itemName, | ||||
| lotNo = lotNo, | 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) | ||||
| @@ -98,13 +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.0 | 2026-09-07 */ | |||||
| /** 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.0 | 2026-09-07 */ | |||||
| /** 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) | ||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -100,7 +100,7 @@ stock_one 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?, | ||||
| @@ -154,6 +154,7 @@ stock_one AS ( | |||||
| """ | """ | ||||
| ${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, | ||||
| @@ -229,7 +230,7 @@ stock_one 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 | ||||
| @@ -262,7 +263,7 @@ stock_one 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)) { | ||||
| @@ -288,7 +289,7 @@ stock_one 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)) { | ||||
| @@ -310,7 +311,7 @@ stock_one 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)) { | ||||
| @@ -354,7 +355,7 @@ stock_one 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>>, | ||||
| @@ -373,8 +374,9 @@ stock_one 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) | |||||
| } | |||||
| } | |||||
| @@ -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, | ||||
| @@ -210,11 +211,13 @@ class StockLotOnhandReportController( | |||||
| val reportTitle = "庫存批次現況報告" | val reportTitle = "庫存批次現況報告" | ||||
| val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) | val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) | ||||
| val headers = listOf( | val headers = listOf( | ||||
| "庫存批次行", | |||||
| "貨品編號", "貨品名稱", "貨品單位結餘", "單位", | "貨品編號", "貨品名稱", "貨品單位結餘", "單位", | ||||
| "批號", "到期日", | "批號", "到期日", | ||||
| "樓層", "倉庫", "區域", "儲位", | "樓層", "倉庫", "區域", "儲位", | ||||
| "最後異動日", "最後異動(入庫/出庫)", | "最後異動日", "最後異動(入庫/出庫)", | ||||
| "批號結餘", "單位均價", "庫存總價值", | "批號結餘", "單位均價", "庫存總價值", | ||||
| "備註", | |||||
| ) | ) | ||||
| val totalColumns = headers.size | val totalColumns = headers.size | ||||
| var rowIndex = 0 | var rowIndex = 0 | ||||
| @@ -284,21 +287,23 @@ class StockLotOnhandReportController( | |||||
| dbData.forEach { m -> | dbData.forEach { m -> | ||||
| val r = sheet.createRow(rowIndex++) | val r = sheet.createRow(rowIndex++) | ||||
| r.heightInPoints = 18f | r.heightInPoints = 18f | ||||
| setTextCell(r, 0, m["itemNo"], styles.text) | |||||
| setTextCell(r, 1, m["itemName"], styles.text) | |||||
| setNumberCell(r, 2, m["totalQtyRaw"], styles.number) | |||||
| setTextCell(r, 3, m["unitOfMeasure"], styles.center) | |||||
| setTextCell(r, 4, m["lotNo"], styles.text) | |||||
| setTextCell(r, 5, m["expiryDate"], styles.center) | |||||
| setTextCell(r, 6, m["storeId"], styles.center) | |||||
| setTextCell(r, 7, m["warehousePart"], styles.center) | |||||
| setTextCell(r, 8, m["areaPart"], styles.center) | |||||
| setTextCell(r, 9, m["slotPart"], styles.center) | |||||
| setTextCell(r, 10, m["lastTrnDate"], styles.center) | |||||
| setTextCell(r, 11, m["lastTrnType"], styles.center) | |||||
| setNumberCell(r, 12, m["lotQtyRaw"], styles.number) | |||||
| setNumberCell(r, 13, m["avgUnitPriceRaw"], styles.number) | |||||
| setNumberCell(r, 14, m["stockValueRaw"], styles.number) | |||||
| setTextCell(r, 0, m["inventoryLotLineId"], styles.center) | |||||
| setTextCell(r, 1, m["itemNo"], styles.text) | |||||
| setTextCell(r, 2, m["itemName"], styles.text) | |||||
| setNumberCell(r, 3, m["totalQtyRaw"], styles.number) | |||||
| setTextCell(r, 4, m["unitOfMeasure"], styles.center) | |||||
| setTextCell(r, 5, m["lotNo"], styles.text) | |||||
| setTextCell(r, 6, m["expiryDate"], styles.center) | |||||
| setTextCell(r, 7, m["storeId"], styles.center) | |||||
| setTextCell(r, 8, m["warehousePart"], styles.center) | |||||
| setTextCell(r, 9, m["areaPart"], styles.center) | |||||
| setTextCell(r, 10, m["slotPart"], styles.center) | |||||
| setTextCell(r, 11, m["lastTrnDate"], styles.center) | |||||
| setTextCell(r, 12, m["lastTrnType"], styles.center) | |||||
| setNumberCell(r, 13, m["lotQtyRaw"], styles.number) | |||||
| setNumberCell(r, 14, m["avgUnitPriceRaw"], styles.number) | |||||
| setNumberCell(r, 15, m["stockValueRaw"], styles.number) | |||||
| setTextCell(r, 16, m["remarks"], styles.text) | |||||
| } | } | ||||
| } | } | ||||
| @@ -307,7 +312,7 @@ class StockLotOnhandReportController( | |||||
| sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, totalColumns - 1)) | sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, totalColumns - 1)) | ||||
| } | } | ||||
| sheet.createFreezePane(0, headerRowIndex + 1) | sheet.createFreezePane(0, headerRowIndex + 1) | ||||
| intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) | |||||
| intArrayOf(14, 14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14, 28) | |||||
| .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | ||||
| val out = ByteArrayOutputStream() | val out = ByteArrayOutputStream() | ||||
| @@ -237,7 +237,7 @@ WHERE ill.id = :id | |||||
| 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.0 | 2026-09-07 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ | |||||
| fun findExpiredItems( | fun findExpiredItems( | ||||
| @Param("untilDate") untilDate: LocalDate, | @Param("untilDate") untilDate: LocalDate, | ||||
| @Param("itemCode") itemCode: String?, | @Param("itemCode") itemCode: String?, | ||||
| @@ -86,7 +86,7 @@ open class StockIssueService( | |||||
| return searchHandleRecords(request, "Bad") | return searchHandleRecords(request, "Bad") | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */ | |||||
| /** 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 hasHandledDateFilter = | val hasHandledDateFilter = | ||||
| request.handledStartDate != null || request.handledEndDate != null | request.handledStartDate != null || request.handledEndDate != null | ||||