Переглянути джерело

過期品處理

production
CANCERYS\kw093 1 тиждень тому
джерело
коміт
fbe77690db
11 змінених файлів з 524 додано та 20 видалено
  1. +18
    -8
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  2. +35
    -5
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt
  3. +3
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt
  4. +247
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt
  5. +126
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertReportService.kt
  6. +54
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt
  7. +8
    -6
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  8. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt
  9. +23
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt
  10. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt
  11. +2
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt

+ 18
- 8
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt Переглянути файл

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

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
open fun getExpiryItemList(
expiryDate: LocalDate? = null,
itemCode: String? = null,
itemName: String? = null,
lotNo: String? = null,
daysAhead: Int? = null,
): List<ExpiryItemResponse> {
val today = LocalDate.now()
val days = (daysAhead ?: 7).coerceIn(0, 365)
val untilDate = today.plusDays(days.toLong())
val normalizedItemCode = itemCode?.trim()?.takeIf { it.isNotEmpty() }
val normalizedItemName = itemName?.trim()?.takeIf { it.isNotEmpty() }
val normalizedLotNo = lotNo?.trim()?.takeIf { it.isNotEmpty() }
val lotLines = inventoryLotLineRepository.findExpiredItems(
today = today,
expiryDate = expiryDate,
untilDate = untilDate,
itemCode = normalizedItemCode,
itemName = normalizedItemName,
lotNo = normalizedLotNo,
)
return lotLines.map { lotLine ->
val lot = lotLine.inventoryLot
val item = lot?.item // Get item from inventoryLot
val expiry = lot?.expiryDate
ExpiryItemResponse(
id = lotLine.id ?: 0L,
itemId = item?.id ?: 0L,
@@ -1840,8 +1846,10 @@ open fun getExpiryItemList(
lotId = lot?.id ?: 0L,
lotNo = lot?.lotNo,
storeLocation = lotLine.warehouse?.code, // Construct from warehouse
expiryDate = lot?.expiryDate,
remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO)
expiryDate = expiry,
remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO),
uomDesc = lotLine.stockUom?.uom?.udfudesc,
canHandle = expiry != null && !expiry.isAfter(today),
)
}
}
@@ -1993,6 +2001,7 @@ open fun submitBadItem(request: SubmitIssueRequest): MessageResponse {
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@Transactional(rollbackFor = [Exception::class])
open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
try {
@@ -2011,7 +2020,7 @@ open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
val lot = lotLine.inventoryLot
val today = LocalDate.now()
if (lot?.expiryDate == null || !lot.expiryDate!!.isBefore(today)) {
if (lot?.expiryDate == null || lot.expiryDate!!.isAfter(today)) {
return MessageResponse(
id = null,
name = "Error",
@@ -2201,6 +2210,7 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse {
}
}
// Fix batchSubmitExpiryItem method (around line 945):
/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@Transactional(rollbackFor = [Exception::class])
open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse {
try {
@@ -2209,8 +2219,8 @@ open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageRespon
val lot = it.inventoryLot
val today = LocalDate.now()
lot?.expiryDate != null &&
lot.expiryDate!!.isBefore(today) &&
(it.inQty ?: BigDecimal.ZERO) != (it.outQty ?: BigDecimal.ZERO)
lot.expiryDate!!.let { !it.isAfter(today) } &&
(it.inQty ?: BigDecimal.ZERO) > (it.outQty ?: BigDecimal.ZERO)
}

if (lotLines.isEmpty()) {


+ 35
- 5
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt Переглянути файл

@@ -6,15 +6,18 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssue
import com.ffii.fpsms.modules.pickOrder.enums.PickExecutionIssueEnum
import com.ffii.fpsms.modules.pickOrder.service.PickExecutionIssueService // 修复导入路径
import com.ffii.fpsms.modules.pickOrder.web.models.*
import org.springframework.format.annotation.DateTimeFormat
import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService
import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters
import com.ffii.fpsms.modules.report.web.lotExpiryAlertExcelResponse
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.time.LocalDate


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

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

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@GetMapping("/issues/expiryItem")
fun getExpiryItemIssues(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) expiryDate: LocalDate?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) itemName: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) daysAhead: Int?,
): List<ExpiryItemResponse> {
return pickExecutionIssueService.getExpiryItemList(
expiryDate = expiryDate,
itemCode = itemCode,
itemName = itemName,
lotNo = lotNo,
daysAhead = daysAhead,
)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@GetMapping("/issues/expiryItem/excel")
fun exportExpiryItemExcel(
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) itemName: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) bucket: String?,
@RequestParam(required = false) daysAhead: Int?,
): ResponseEntity<ByteArray> {
return lotExpiryAlertExcelResponse(
lotExpiryAlertReportService.exportExcel(
SearchFilters(
itemCode = itemCode,
itemName = itemName,
lotNo = lotNo,
bucket = bucket,
daysAhead = daysAhead,
),
),
)
}

@PostMapping("/submitMissItem")
fun submitMissItem(@RequestBody request: SubmitIssueRequest): MessageResponse {
return pickExecutionIssueService.submitMissItem(request)
@@ -99,11 +127,13 @@ fun batchSubmitBadItem(@RequestBody request: BatchSubmitIssueRequest): MessageRe
return pickExecutionIssueService.batchSubmitBadItem(request)
}

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

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


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt Переглянути файл

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


+ 247
- 0
src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt Переглянути файл

@@ -0,0 +1,247 @@
package com.ffii.fpsms.modules.report.service

import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.CellStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Row
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.streaming.SXSSFWorkbook
import java.io.ByteArrayOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

internal object LotExpiryAlertExcelBuilder {
private data class ExcelStyles(
val title: CellStyle,
val subtitle: CellStyle,
val header: CellStyle,
val text: CellStyle,
val center: CellStyle,
val number: CellStyle,
)

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
fun build(
dbData: List<Map<String, Any>>,
filters: SearchFilters,
): ByteArray {
val workbook = SXSSFWorkbook(100)
workbook.setCompressTempFiles(true)
try {
val styles = createStyles(workbook)
val reportTitle = "到期警示報告"
val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle))
val headers = listOf(
"到期警示",
"樓層",
"到期日",
"貨品編號",
"貨品名稱",
"批號",
"倉庫",
"區域",
"儲位",
"剩餘數量",
"單位",
)
val totalColumns = headers.size
var rowIndex = 0

val titleRow = sheet.createRow(rowIndex++)
titleRow.heightInPoints = 24f
titleRow.createCell(0).apply {
setCellValue(reportTitle)
cellStyle = styles.title
}
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

val downloadedAt = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
val dateRow = sheet.createRow(rowIndex++)
dateRow.heightInPoints = 32f
dateRow.createCell(0).apply {
setCellValue("下載日期:$downloadedAt")
cellStyle = styles.subtitle
}
val dateEndCol = 3.coerceAtMost(totalColumns - 2)
if (dateEndCol > 0) {
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, dateEndCol))
}
val filterStartCol = dateEndCol + 1
val filterText = listOf(
"貨品編號=${displayFilter(filters.itemCode)}",
"貨品=${displayFilter(filters.itemName)}",
"批號=${displayFilter(filters.lotNo)}",
"未來天數=${filters.resolvedDaysAhead()}",
"到期分類=${displayBucket(filters.bucket, filters.resolvedDaysAhead())}",
).joinToString(" ")
dateRow.createCell(filterStartCol).apply {
setCellValue("搜尋條件:$filterText")
cellStyle = styles.subtitle
}
if (filterStartCol < totalColumns - 1) {
sheet.addMergedRegion(CellRangeAddress(1, 1, filterStartCol, totalColumns - 1))
}

val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headerRow.heightInPoints = 22f
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
}
}

if (dbData.isEmpty()) {
val emptyRowIndex = rowIndex
val r = sheet.createRow(rowIndex++)
r.heightInPoints = 22f
r.createCell(0).apply {
setCellValue("查無資料")
cellStyle = styles.center
}
for (c in 1 until totalColumns) {
r.createCell(c).cellStyle = styles.center
}
sheet.addMergedRegion(CellRangeAddress(emptyRowIndex, emptyRowIndex, 0, totalColumns - 1))
} else {
dbData.forEach { m ->
val r = sheet.createRow(rowIndex++)
r.heightInPoints = 18f
setTextCell(r, 0, m["expiryAlert"], styles.center)
setTextCell(r, 1, m["storeId"], styles.center)
setTextCell(r, 2, m["expiryDate"], styles.center)
setTextCell(r, 3, m["itemCode"], styles.text)
setTextCell(r, 4, m["itemName"], styles.text)
setTextCell(r, 5, m["lotNumber"], styles.text)
setTextCell(r, 6, m["warehouse"], styles.center)
setTextCell(r, 7, m["area"], styles.center)
setTextCell(r, 8, m["slot"], styles.center)
setNumberCell(r, 9, m["remainingQty"], styles.number)
setTextCell(r, 10, m["unitOfMeasure"], styles.center)
}
}

val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, totalColumns - 1))
}
sheet.createFreezePane(0, headerRowIndex + 1)
intArrayOf(14, 10, 12, 14, 28, 18, 12, 10, 10, 12, 16)
.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.write(out)
return out.toByteArray()
} finally {
workbook.dispose()
workbook.close()
}
}

private fun createStyles(workbook: Workbook): ExcelStyles {
val numberFormat = workbook.createDataFormat().getFormat("#,##0.00;(#,##0.00)")
fun borders(style: CellStyle) {
style.borderTop = BorderStyle.THIN
style.borderBottom = BorderStyle.THIN
style.borderLeft = BorderStyle.THIN
style.borderRight = BorderStyle.THIN
style.verticalAlignment = VerticalAlignment.CENTER
}
val titleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
bold = true
fontHeightInPoints = 16
}
setFont(font)
}
val subtitleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
wrapText = true
val font = workbook.createFont().apply {
fontHeightInPoints = 10
}
setFont(font)
}
val headerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
wrapText = true
borders(this)
fillForegroundColor = IndexedColors.DARK_TEAL.index
fillPattern = FillPatternType.SOLID_FOREGROUND
val font = workbook.createFont().apply {
bold = true
color = IndexedColors.WHITE.index
fontHeightInPoints = 10
}
setFont(font)
}
val textStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
borders(this)
}
val centerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
borders(this)
}
val numberStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
borders(this)
dataFormat = numberFormat
}
return ExcelStyles(
title = titleStyle,
subtitle = subtitleStyle,
header = headerStyle,
text = textStyle,
center = centerStyle,
number = numberStyle,
)
}

private fun displayFilter(raw: String?): String {
val v = raw?.trim().orEmpty()
return if (v.isEmpty()) "全部" else v
}

private fun displayBucket(raw: String?, daysAhead: Int): String {
return when (raw?.trim()?.lowercase()) {
"expired" -> "過期尚未處理"
"today" -> "今日到期"
"upcoming" -> "未來 ${daysAhead} 日到期"
else -> "全部"
}
}

private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) {
row.createCell(col).apply {
setCellValue(value?.toString() ?: "")
cellStyle = style
}
}

private fun setNumberCell(row: Row, col: Int, value: Any?, numberStyle: CellStyle) {
val cell = row.createCell(col)
val n = when (value) {
null -> null
is Number -> value.toDouble()
else -> value.toString().replace(",", "").toDoubleOrNull()
}
if (n == null) {
cell.setCellValue("")
cell.cellStyle = numberStyle
return
}
cell.setCellValue(n)
cell.cellStyle = numberStyle
}
}

+ 126
- 0
src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertReportService.kt Переглянути файл

@@ -0,0 +1,126 @@
package com.ffii.fpsms.modules.report.service

import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Service

@Service
open class LotExpiryAlertReportService(
private val jdbcDao: JdbcDao,
) {
data class SearchFilters(
val itemCode: String? = null,
val itemName: String? = null,
val lotNo: String? = null,
val bucket: String? = null,
val daysAhead: Int? = null,
) {
fun resolvedDaysAhead(): Int {
val n = daysAhead ?: DEFAULT_DAYS_AHEAD
return n.coerceIn(0, MAX_DAYS_AHEAD)
}

companion object {
const val DEFAULT_DAYS_AHEAD = 7
const val MAX_DAYS_AHEAD = 365
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
fun search(
itemCode: String? = null,
itemName: String? = null,
lotNo: String? = null,
bucket: String? = null,
daysAhead: Int? = null,
): List<Map<String, Any>> = search(
SearchFilters(
itemCode = itemCode,
itemName = itemName,
lotNo = lotNo,
bucket = bucket,
daysAhead = daysAhead,
),
)

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
fun search(filters: SearchFilters): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val daysAhead = filters.resolvedDaysAhead()
args["daysAhead"] = daysAhead
val extraWhere = buildString {
val itemCode = filters.itemCode?.trim().orEmpty()
if (itemCode.isNotEmpty()) {
args["itemCode"] = itemCode
append(" AND LOWER(i.code) LIKE LOWER(CONCAT('%', :itemCode, '%'))")
}
val itemName = filters.itemName?.trim().orEmpty()
if (itemName.isNotEmpty()) {
args["itemName"] = itemName
append(" AND LOWER(i.name) LIKE LOWER(CONCAT('%', :itemName, '%'))")
}
val lotNo = filters.lotNo?.trim().orEmpty()
if (lotNo.isNotEmpty()) {
args["lotNo"] = lotNo
append(" AND LOWER(il.lotNo) LIKE LOWER(CONCAT('%', :lotNo, '%'))")
}
when (filters.bucket?.trim()?.lowercase()) {
"expired" -> append(" AND il.expiryDate < CURRENT_DATE")
"today" -> append(" AND il.expiryDate = CURRENT_DATE")
"upcoming" -> append(
" AND il.expiryDate > CURRENT_DATE AND il.expiryDate <= DATE_ADD(CURRENT_DATE, INTERVAL :daysAhead DAY)",
)
}
}
return jdbcDao.queryForList(
"""
SELECT
COALESCE(i.code, '') AS itemCode,
COALESCE(i.name, '') AS itemName,
COALESCE(il.lotNo, '') AS lotNumber,
COALESCE(w.store_id, '') AS storeId,
COALESCE(w.warehouse, '') AS warehouse,
COALESCE(w.area, '') AS area,
COALESCE(w.slot, '') AS slot,
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS remainingQty,
COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure,
CASE
WHEN il.expiryDate IS NULL THEN ''
WHEN il.expiryDate < CURRENT_DATE THEN '已過期'
WHEN il.expiryDate = CURRENT_DATE THEN '今日到期'
WHEN il.expiryDate <= DATE_ADD(CURRENT_DATE, INTERVAL :daysAhead DAY)
THEN '${daysAhead}日內到期'
ELSE ''
END AS expiryAlert
FROM inventory_lot_line ill
LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id
LEFT JOIN warehouse w ON ill.warehouseId = w.id
LEFT JOIN items i ON il.itemId = i.id
LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0
AND COALESCE(ill.deleted, 0) = 0
AND COALESCE(il.deleted, 0) = 0
AND COALESCE(i.deleted, 0) = 0
AND il.expiryDate IS NOT NULL
AND il.expiryDate <= DATE_ADD(CURRENT_DATE, INTERVAL :daysAhead DAY)
$extraWhere
ORDER BY
CASE
WHEN il.expiryDate < CURRENT_DATE THEN 1
WHEN il.expiryDate = CURRENT_DATE THEN 2
ELSE 3
END,
COALESCE(w.store_id, ''),
il.expiryDate,
COALESCE(i.code, '')
""".trimIndent(),
args,
) as List<Map<String, Any>>
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
fun exportExcel(filters: SearchFilters = SearchFilters()): ByteArray {
return LotExpiryAlertExcelBuilder.build(search(filters), filters)
}
}

+ 54
- 0
src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt Переглянути файл

@@ -0,0 +1,54 @@
package com.ffii.fpsms.modules.report.web

import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService
import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

internal fun lotExpiryAlertExcelResponse(bytes: ByteArray): ResponseEntity<ByteArray> {
val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
setContentDispositionFormData("attachment", "LotExpiryAlertReport.xlsx")
set("filename", "LotExpiryAlertReport.xlsx")
}
return ResponseEntity(bytes, headers, HttpStatus.OK)
}

/**
* Lot expiry alert (Excel only).
* Excel: /report/print-lot-expiry-alert-excel
*/
@RestController
@RequestMapping("/report")
class LotExpiryAlertReportController(
private val lotExpiryAlertReportService: LotExpiryAlertReportService,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
@GetMapping("/print-lot-expiry-alert-excel")
fun exportExcel(
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) itemName: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) bucket: String?,
@RequestParam(required = false) daysAhead: Int?,
): ResponseEntity<ByteArray> {
val excelBytes = lotExpiryAlertReportService.exportExcel(
SearchFilters(
itemCode = itemCode,
itemName = itemName,
lotNo = lotNo,
bucket = bucket,
daysAhead = daysAhead,
),
)
return lotExpiryAlertExcelResponse(excelBytes)
}
}

+ 8
- 6
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt Переглянути файл

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

@EntityGraph(
type = EntityGraph.EntityGraphType.FETCH,
attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"]
attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse", "stockUom", "stockUom.uom"]
)
@Query("""
SELECT ill FROM InventoryLotLine ill
JOIN ill.inventoryLot il
JOIN il.item i
WHERE il.expiryDate < :today
AND (:expiryDate IS NULL OR il.expiryDate = :expiryDate)
WHERE il.expiryDate IS NOT NULL
AND il.expiryDate <= :untilDate
AND (:itemCode IS NULL OR LOWER(i.code) LIKE LOWER(CONCAT('%', :itemCode, '%')))
AND (:itemName IS NULL OR LOWER(i.name) LIKE LOWER(CONCAT('%', :itemName, '%')))
AND coalesce(ill.inQty, 0) <> coalesce(ill.outQty, 0)
AND (:lotNo IS NULL OR LOWER(il.lotNo) LIKE LOWER(CONCAT('%', :lotNo, '%')))
AND coalesce(ill.inQty, 0) > coalesce(ill.outQty, 0)
AND ill.deleted = false
AND il.deleted = false
AND i.deleted = false
ORDER BY il.expiryDate ASC
""")
/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
fun findExpiredItems(
@Param("today") today: LocalDate,
@Param("expiryDate") expiryDate: LocalDate?,
@Param("untilDate") untilDate: LocalDate,
@Param("itemCode") itemCode: String?,
@Param("itemName") itemName: String?,
@Param("lotNo") lotNo: String?,
): List<InventoryLotLine>

/**


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt Переглянути файл

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

+ 23
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt Переглянути файл

@@ -86,8 +86,20 @@ open class StockIssueService(
return searchHandleRecords(request, "Bad")
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */
open fun getExpiryItemHandleRecords(request: SearchStockIssueRecordRequest): RecordsRes<StockIssueHandleRecordResponse> {
val (startDate, endDateExclusive) = resolveDateRange(request.startDate, request.endDate)
val hasHandledDateFilter =
request.handledStartDate != null || request.handledEndDate != null
val (startDate, endDateExclusive) =
if (hasHandledDateFilter && request.startDate == null && request.endDate == null) {
Pair(null, null)
} else {
resolveDateRange(request.startDate, request.endDate)
}
val (handledStartDate, handledEndDateExclusive) = resolveOptionalDateRange(
request.handledStartDate,
request.handledEndDate,
)
val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() }
val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() }
val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() }
@@ -103,6 +115,8 @@ open class StockIssueService(
lotNo = lotNo,
startDate = startDate,
endDateExclusive = endDateExclusive,
handledStartDate = handledStartDate,
handledEndDateExclusive = handledEndDateExclusive,
pageable = pageable,
)

@@ -144,6 +158,14 @@ open class StockIssueService(
return Pair(start, endInclusive.plusDays(1))
}

/** Inclusive start/end; no default window when both are empty. */
private fun resolveOptionalDateRange(startDate: LocalDate?, endDate: LocalDate?): Pair<LocalDate?, LocalDate?> {
if (startDate == null && endDate == null) return Pair(null, null)
val start = startDate ?: endDate
val endInclusive = endDate ?: startDate
return Pair(start, endInclusive?.plusDays(1))
}

private fun toRecordResponse(ledger: StockLedger): StockIssueHandleRecordResponse {
val stockOutLine = ledger.stockOutLine
val lotLine = stockOutLine?.inventoryLotLine


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt Переглянути файл

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


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt Переглянути файл

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


Завантаження…
Відмінити
Зберегти