diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt index 94a9038..fc2d1b6 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service open class ItemQcFailReportService( private val jdbcDao: JdbcDao, ) { + /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ fun searchItemQcFailReport( stockCategory: String?, itemCode: String?, @@ -33,16 +34,19 @@ open class ItemQcFailReportService( val qcItemScopeSql = buildQcItemScopeClause(measurable, other) val measuredValueSql = buildMeasuredValueClause(measurable, scope) + // Date filter = QC result created time (true QC finish), not receiptDate / put-away. + // JO EPQC: receiptDate is often SIL create day; put-away ≈ QC via auto put-away. + // PO IQC: put-away can be days after QC — must not use ill.created for this filter. val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { val formattedDate = lastInDateStart.replace("/", "-") args["lastInDateStart"] = formattedDate - "AND DATE(sil.receiptDate) >= DATE(:lastInDateStart)" + "AND DATE(qr.created) >= DATE(:lastInDateStart)" } else "" val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { val formattedDate = lastInDateEnd.replace("/", "-") args["lastInDateEnd"] = formattedDate - "AND DATE(sil.receiptDate) <= DATE(:lastInDateEnd)" + "AND DATE(qr.created) <= DATE(:lastInDateEnd)" } else "" val sql = """ diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 4ab4978..55c546b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -4,17 +4,25 @@ import com.ffii.core.support.JdbcDao import org.springframework.stereotype.Service import java.time.LocalDate import java.time.format.DateTimeFormatter + @Service open class StockLedgerReportService( private val jdbcDao: JdbcDao, ) { + private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") + /** + * FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 * Stock Ledger 報表查詢 * * - stockSubCategory = items.type * - trnDate = stock_ledger.date * - trnRefNo = stock_ledger.type + * - cumBalance = stock_ledger.balance(異動後纍計存量) + * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) + * + * 只查 [start, end] 期間列,不掃起日以前全歷史。 */ fun searchStockLedgerReport( stockCategory: String?, @@ -23,18 +31,15 @@ open class StockLedgerReportService( reportPeriodStart: String?, reportPeriodEnd: String?, ): List> { - + val args = mutableMapOf() - - // 1) 先決定 reportPeriodEnd:如果有填 end,就用使用者的;否則用今天 + val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") - ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) - - // 2) 如果有填 start,就用使用者的;否則從 DB 查最早一筆日期 + ?: LocalDate.now().format(dateFmt)) + val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { reportPeriodStart.replace("/", "-") } else { - // 用簡單 SQL 查全表最早一筆日期(可加上類似 stockCategory/itemCode 過濾) val minDateSql = """ SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate FROM stock_ledger sl @@ -42,188 +47,39 @@ open class StockLedgerReportService( AND sl.itemCode IS NOT NULL AND sl.itemCode <> '' """.trimIndent() - + val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap()).firstOrNull() (minDateRow?.get("firstDate") as? String) - ?: reportPeriodEnd // 如果表是空的,就退回用今天 + ?: reportPeriodEnd } - - // 3) 把 from/to 塞到 args,供後面 SQL 使用 + + val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) args["reportPeriodStart"] = reportPeriodStart - args["reportPeriodEnd"] = reportPeriodEnd - - // 4) 之後再用你原來的 stockCategorySql / itemCodeSql / storeLocationSql + args["reportPeriodEndExclusive"] = endExclusive + val stockCategorySql = buildMultiValueExactClause( stockCategory, "it.type", "stockCategory", args ) - + val itemCodeSql = buildMultiValueLikeClause( itemCode, "sl.itemCode", "itemCode", args ) - + + // 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 val storeLocationSql = if (!storeLocation.isNullOrBlank()) { args["storeLocation"] = "%$storeLocation%" - "AND (wh_in.code LIKE :storeLocation OR wh_out.code LIKE :storeLocation)" + "AND lot.storeLocation LIKE :storeLocation" } else { "" } - - val reportPeriodEndSql = "AND DATE(sl.date) <= :reportPeriodEnd" - val sql = """ -WITH base AS ( - SELECT - sl.id AS slId, - DATE(sl.date) AS trnDateRaw, - DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, - CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' - AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' - ELSE COALESCE(sl.type, '') - END AS trnRefNoRaw, - - sl.itemId AS itemId, - sl.itemCode AS itemCode, - - COALESCE(sl.inQty, 0) AS inQty, - COALESCE(sl.outQty, 0) AS outQty, - (COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS delta, - - it.type AS stockSubCategory, - it.code AS itemNo, - it.name AS itemName, - uc.udfudesc AS unitOfMeasure, - - lot.lotNo AS lotNo, - COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, - lot.storeLocation AS storeLocation, - - COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, - COALESCE(TRIM(jo.code), '') AS jobOrderNo, - - '' AS openingBalance, - '' AS cumStockIn, - '' AS cumStockOut, - '' AS currentBalance, - '' AS lastInDate, - '' AS lastOutDate, - '' AS reOrderLevel, - '' AS reOrderQty - FROM stock_ledger sl - LEFT JOIN stock_in_line sil - ON sl.stockInLineId = sil.id - AND sil.deleted = 0 - LEFT JOIN inventory_lot il_in - ON sil.inventoryLotId = il_in.id - AND il_in.deleted = 0 - - LEFT JOIN stock_out_line sol - ON sl.stockOutLineId = sol.id - AND sol.deleted = 0 - LEFT JOIN inventory_lot_line ill_out - ON sol.inventoryLotLineId = ill_out.id - AND ill_out.deleted = 0 - LEFT JOIN inventory_lot il_out - ON ill_out.inventoryLotId = il_out.id - AND il_out.deleted = 0 - - LEFT JOIN ( - SELECT - il.id AS lotId, - il.lotNo AS lotNo, - il.expiryDate AS expiryDate, - MAX(wh.code) AS storeLocation - FROM inventory_lot il - LEFT JOIN inventory_lot_line ill - ON ill.inventoryLotId = il.id - AND ill.deleted = 0 - LEFT JOIN warehouse wh - ON ill.warehouseId = wh.id - AND wh.deleted = 0 - GROUP BY - il.id, il.lotNo, il.expiryDate - ) lot - ON lot.lotId = COALESCE(il_in.id, il_out.id) - - LEFT JOIN items it - ON sl.itemId = it.id - AND it.deleted = 0 - LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 - AND iu.deleted = 0 - LEFT JOIN uom_conversion uc - ON iu.uomId = uc.id - - -- 這兩個 alias 是為了配合你上面 storeLocationSql 的 wh_in / wh_out - LEFT JOIN inventory_lot_line ill_in - ON il_in.id = ill_in.inventoryLotId - AND ill_in.deleted = 0 - LEFT JOIN warehouse wh_in - ON ill_in.warehouseId = wh_in.id - AND wh_in.deleted = 0 - LEFT JOIN warehouse wh_out - ON ill_out.warehouseId = wh_out.id - AND wh_out.deleted = 0 - LEFT JOIN stock_out so - ON sol.stockOutId = so.id - AND so.deleted = 0 - LEFT JOIN pick_order_line pol - ON sol.pickOrderLineId = pol.id - AND pol.deleted = 0 - LEFT JOIN pick_order po_out - ON pol.poId = po_out.id - AND po_out.deleted = 0 - LEFT JOIN job_order jo_po - ON po_out.joId = jo_po.id - AND jo_po.deleted = 0 - LEFT JOIN delivery_order do - ON po_out.doId = do.id - AND do.deleted = 0 - LEFT JOIN stock_in si - ON sil.stockInId = si.id - AND si.deleted = 0 - LEFT JOIN job_order jo - ON sil.jobOrderId = jo.id - AND jo.deleted = 0 - LEFT JOIN purchase_order po - ON sil.purchaseOrderId = po.id - AND po.deleted = 0 - WHERE - sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - AND DATE(sl.date) <= :reportPeriodEnd - $stockCategorySql - $itemCodeSql - $storeLocationSql - AND lot.lotId IS NOT NULL -), -opening AS ( - SELECT - itemCode, - COALESCE(SUM(delta), 0) AS openingBeforeStart - FROM base - WHERE trnDateRaw < :reportPeriodStart - GROUP BY itemCode -), -period AS ( - SELECT - b.*, - COALESCE(o.openingBeforeStart, 0) AS openingBeforeStart - FROM base b - LEFT JOIN opening o - ON o.itemCode = b.itemCode - WHERE b.trnDateRaw BETWEEN :reportPeriodStart AND :reportPeriodEnd -) SELECT stockSubCategory, itemNo, @@ -231,7 +87,7 @@ SELECT unitOfMeasure, lotNo, expiryDate, - trnDate, + trnDate, CASE trnRefNoRaw WHEN 'OPEN' THEN '開倉' WHEN 'NOR' THEN '出入倉' @@ -255,39 +111,171 @@ SELECT reOrderLevel, reOrderQty, - -- jrxml 需要 String;負數括號顯示,無小數 - CASE WHEN COALESCE(inQty, 0) < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(COALESCE(inQty, 0), 0) END AS stockIn, - CASE WHEN COALESCE(outQty, 0) < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(COALESCE(outQty, 0), 0) END AS stockOut, - - -- 累計存量(跨 lot:只用 itemCode 分區) - CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)) < 0 - THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)), 0), ')') - ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId), 0) END AS cumBalance, - - -- 累計期初存量 = 本行累計 - 本行異動 - CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta) < 0 - THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta), 0), ')') - ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta, 0) END AS cumOpeningBal, - - -- footer totals(同樣輸出 String) - CASE WHEN COALESCE(openingBeforeStart, 0) < 0 THEN CONCAT('(', FORMAT(-openingBeforeStart, 0), ')') ELSE FORMAT(COALESCE(openingBeforeStart, 0), 0) END AS totalCumOpeningBal, - CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockIn, - CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockOut, - CASE WHEN (openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)) < 0 - THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)), 0), ')') - ELSE FORMAT(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode), 0) END AS totalCumBalance - -FROM period + CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn, + CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut, + + CASE WHEN cumOpeningBalRaw < 0 + THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')') + ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal, + CASE WHEN bal < 0 + THEN CONCAT('(', FORMAT(-bal, 0), ')') + ELSE FORMAT(bal, 0) END AS cumBalance, + + CASE WHEN totalCumOpeningBalRaw < 0 + THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')') + ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal, + CASE WHEN totalStockInRaw < 0 + THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')') + ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn, + CASE WHEN totalStockOutRaw < 0 + THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')') + ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut, + CASE WHEN totalCumBalanceRaw < 0 + THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')') + ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance + +FROM ( + SELECT + x.*, + FIRST_VALUE(cumOpeningBalRaw) OVER ( + PARTITION BY itemCode ORDER BY trnDateRaw, slId + ) AS totalCumOpeningBalRaw, + SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw, + SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw, + FIRST_VALUE(bal) OVER ( + PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC + ) AS totalCumBalanceRaw + FROM ( + SELECT + sl.id AS slId, + DATE(sl.date) AS trnDateRaw, + DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, + CASE + WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' + WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' + AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' + ELSE COALESCE(sl.type, '') + END AS trnRefNoRaw, + + sl.itemCode AS itemCode, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty, + COALESCE(sl.balance, 0) AS bal, + COALESCE(sl.balance, 0) + - COALESCE(sl.inQty, 0) + + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, + + it.type AS stockSubCategory, + it.code AS itemNo, + it.name AS itemName, + uc.udfudesc AS unitOfMeasure, + + lot.lotNo AS lotNo, + COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + lot.storeLocation AS storeLocation, + + COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, + COALESCE(TRIM(jo.code), '') AS jobOrderNo, + + '' AS openingBalance, + '' AS cumStockIn, + '' AS cumStockOut, + '' AS currentBalance, + '' AS lastInDate, + '' AS lastOutDate, + '' AS reOrderLevel, + '' AS reOrderQty + FROM stock_ledger sl + LEFT JOIN stock_in_line sil + ON sl.stockInLineId = sil.id + AND sil.deleted = 0 + LEFT JOIN inventory_lot il_in + ON sil.inventoryLotId = il_in.id + AND il_in.deleted = 0 + + LEFT JOIN stock_out_line sol + ON sl.stockOutLineId = sol.id + AND sol.deleted = 0 + LEFT JOIN inventory_lot_line ill_out + ON sol.inventoryLotLineId = ill_out.id + AND ill_out.deleted = 0 + LEFT JOIN inventory_lot il_out + ON ill_out.inventoryLotId = il_out.id + AND il_out.deleted = 0 + + LEFT JOIN ( + SELECT + il.id AS lotId, + il.lotNo AS lotNo, + il.expiryDate AS expiryDate, + MAX(wh.code) AS storeLocation + FROM inventory_lot il + LEFT JOIN inventory_lot_line ill + ON ill.inventoryLotId = il.id + AND ill.deleted = 0 + LEFT JOIN warehouse wh + ON ill.warehouseId = wh.id + AND wh.deleted = 0 + WHERE il.deleted = 0 + GROUP BY + il.id, il.lotNo, il.expiryDate + ) lot + ON lot.lotId = COALESCE(il_in.id, il_out.id) + + LEFT JOIN items it + ON sl.itemId = it.id + AND it.deleted = 0 + LEFT JOIN item_uom iu + ON it.id = iu.itemId + AND iu.stockUnit = 1 + AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON iu.uomId = uc.id + + LEFT JOIN stock_out so + ON sol.stockOutId = so.id + AND so.deleted = 0 + LEFT JOIN pick_order_line pol + ON sol.pickOrderLineId = pol.id + AND pol.deleted = 0 + LEFT JOIN pick_order po_out + ON pol.poId = po_out.id + AND po_out.deleted = 0 + LEFT JOIN job_order jo_po + ON po_out.joId = jo_po.id + AND jo_po.deleted = 0 + LEFT JOIN delivery_order do + ON po_out.doId = do.id + AND do.deleted = 0 + LEFT JOIN stock_in si + ON sil.stockInId = si.id + AND si.deleted = 0 + LEFT JOIN job_order jo + ON sil.jobOrderId = jo.id + AND jo.deleted = 0 + LEFT JOIN purchase_order po + ON sil.purchaseOrderId = po.id + AND po.deleted = 0 + WHERE + sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + $stockCategorySql + $itemCodeSql + $storeLocationSql + AND lot.lotId IS NOT NULL + ) x +) y ORDER BY itemNo, trnDateRaw, slId, lotNo """.trimIndent() - - val result = jdbcDao.queryForList(sql, args) - return result + return jdbcDao.queryForList(sql, args) } /** LIKE 多值工具方法 */ @@ -327,4 +315,4 @@ ORDER BY } return "AND (${conditions.joinToString(" OR ")})" } -} \ No newline at end of file +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt index c84b90e..a646fae 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt @@ -23,7 +23,7 @@ import java.io.ByteArrayOutputStream class DoInventoryUomMismatchReportController( private val doInventoryUomMismatchReportService: DoInventoryUomMismatchReportService, ) { - /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ @GetMapping("/print-do-inventory-uom-mismatch-excel") fun exportExcel( @RequestParam(required = false) deliveryDate: String?, @@ -69,7 +69,7 @@ class DoInventoryUomMismatchReportController( } val columns = listOf( - "deliveryDate" to "預計到貨日期", + "deliveryDate" to "預計送貨日期", "doCode" to "送貨單號", "supplierCode" to "供應商編號", "supplierName" to "供應商名稱", diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt index 8565087..73902a8 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt @@ -21,6 +21,7 @@ class ItemQcFailReportController( private val itemQcFailReportService: ItemQcFailReportService, ) { + /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-item-qc-fail") fun generateItemQcFailReport( @RequestParam(required = false) stockCategory: String?, @@ -29,7 +30,7 @@ class ItemQcFailReportController( @RequestParam(required = false) lastInDateEnd: String?, @RequestParam(required = false) qcType: String?, @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, - @RequestParam(required = false, defaultValue = "false") includeOther: String?, + @RequestParam(required = false, defaultValue = "true") includeOther: String?, @RequestParam(required = false, defaultValue = "all") measurableScope: String?, ): ResponseEntity { val dbData = itemQcFailReportService.searchItemQcFailReport( @@ -70,6 +71,7 @@ class ItemQcFailReportController( return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } + /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-item-qc-fail-excel") fun exportItemQcFailReportExcel( @RequestParam(required = false) stockCategory: String?, @@ -78,7 +80,7 @@ class ItemQcFailReportController( @RequestParam(required = false) lastInDateEnd: String?, @RequestParam(required = false) qcType: String?, @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, - @RequestParam(required = false, defaultValue = "false") includeOther: String?, + @RequestParam(required = false, defaultValue = "true") includeOther: String?, @RequestParam(required = false, defaultValue = "all") measurableScope: String?, ): ResponseEntity { val dbData = itemQcFailReportService.searchItemQcFailReport( @@ -297,7 +299,7 @@ class ItemQcFailReportController( "不合格數量", "實測值", "備註", - "訂單/工單" + "訂單/工單", ) run { @@ -321,12 +323,16 @@ class ItemQcFailReportController( fun writeNumber(col: Int, value: Any?) { val raw = value?.toString()?.trim() ?: "" - val cleaned = raw.removeSuffix(".") + // SQL FORMAT() may emit thousand separators (e.g. "1,021") — strip before parse. + val cleaned = raw + .replace(",", "") + .replace(" ", "") + .removeSuffix(".") val bd = cleaned.toBigDecimalOrNull() val cell = row.createCell(col) if (bd == null) { - cell.setCellValue(cleaned) + cell.setCellValue(raw) cell.cellStyle = textStyle } else { val stripped = bd.stripTrailingZeros() diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt index e7baa83..5345032 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt @@ -14,10 +14,10 @@ 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.usermodel.XSSFCellStyle -import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.apache.poi.xssf.streaming.SXSSFWorkbook import java.io.ByteArrayOutputStream @RestController @@ -27,66 +27,66 @@ class StockLedgerReportController( private val stockLedgerReportService: StockLedgerReportService, ) { private data class ExcelStyles( - val title: XSSFCellStyle, - val subtitle: XSSFCellStyle, - val header: XSSFCellStyle, - val text: XSSFCellStyle, - val center: XSSFCellStyle, - val int: XSSFCellStyle, - val dash: XSSFCellStyle, - val sumQty: XSSFCellStyle, - val sumLabel: XSSFCellStyle, - val sumEmpty: XSSFCellStyle, - val sumHidden: XSSFCellStyle, + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val int: CellStyle, + val dash: CellStyle, + val sumQty: CellStyle, + val sumLabel: CellStyle, + val sumEmpty: CellStyle, + val sumHidden: CellStyle, ) + /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-stock-ledger") -fun generateStockLedgerReport( - @RequestParam(required = false) stockCategory: String?, - @RequestParam(required = false) itemCode: String?, - @RequestParam(required = false) storeLocation: String?, - // URL 參數名仍然是 lastInDateStart / lastInDateEnd - @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, - @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, -): ResponseEntity { - val parameters = mutableMapOf() - - parameters["stockCategory"] = stockCategory ?: "All" - parameters["stockSubCategory"] = stockCategory ?: "All" - parameters["itemNo"] = itemCode ?: "All" - parameters["year"] = LocalDate.now().year.toString() - parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) - parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + fun generateStockLedgerReport( + @RequestParam(required = false) stockCategory: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) storeLocation: String?, + @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, + @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, + ): ResponseEntity { + val parameters = mutableMapOf() - parameters["storeLocation"] = storeLocation ?: "" - parameters["balanceFilterStart"] = "" - parameters["balanceFilterEnd"] = "" - parameters["reportPeriodStart"] = reportPeriodStart ?: "" - parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" + parameters["stockCategory"] = stockCategory ?: "All" + parameters["stockSubCategory"] = stockCategory ?: "All" + parameters["itemNo"] = itemCode ?: "All" + parameters["year"] = LocalDate.now().year.toString() + parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + parameters["storeLocation"] = storeLocation ?: "" + parameters["balanceFilterStart"] = "" + parameters["balanceFilterEnd"] = "" + parameters["reportPeriodStart"] = reportPeriodStart ?: "" + parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" - val dbData = stockLedgerReportService.searchStockLedgerReport( - stockCategory = stockCategory, - itemCode = itemCode, - storeLocation = storeLocation, - reportPeriodStart = reportPeriodStart, - reportPeriodEnd = reportPeriodEnd, - ) + val dbData = stockLedgerReportService.searchStockLedgerReport( + stockCategory = stockCategory, + itemCode = itemCode, + storeLocation = storeLocation, + reportPeriodStart = reportPeriodStart, + reportPeriodEnd = reportPeriodEnd, + ) - val pdfBytes = reportService.createPdfResponse( - "/jasper/StockLedgarReport.jrxml", - parameters, - dbData - ) + val pdfBytes = reportService.createPdfResponse( + "/jasper/StockLedgarReport.jrxml", + parameters, + dbData, + ) - val headers = HttpHeaders().apply { - contentType = MediaType.APPLICATION_PDF - setContentDispositionFormData("attachment", "StockLedgerReport.pdf") - set("filename", "StockLedgerReport.pdf") + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_PDF + setContentDispositionFormData("attachment", "StockLedgerReport.pdf") + set("filename", "StockLedgerReport.pdf") + } + return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } - return ResponseEntity(pdfBytes, headers, HttpStatus.OK) -} + /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-stock-ledger-excel") fun exportStockLedgerReportExcel( @RequestParam(required = false) stockCategory: String?, @@ -119,8 +119,8 @@ fun generateStockLedgerReport( return ResponseEntity(excelBytes, headers, HttpStatus.OK) } - private fun createStyles(workbook: XSSFWorkbook): ExcelStyles { - val titleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + private fun createStyles(workbook: Workbook): ExcelStyles { + val titleStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.CENTER verticalAlignment = VerticalAlignment.CENTER val font = workbook.createFont().apply { @@ -129,7 +129,7 @@ fun generateStockLedgerReport( } setFont(font) } - val subtitleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val subtitleStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER val font = workbook.createFont().apply { @@ -138,7 +138,7 @@ fun generateStockLedgerReport( } setFont(font) } - val headerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val headerStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.CENTER verticalAlignment = VerticalAlignment.CENTER fillForegroundColor = IndexedColors.GREY_25_PERCENT.index @@ -150,7 +150,7 @@ fun generateStockLedgerReport( val font = workbook.createFont().apply { bold = true } setFont(font) } - val textStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val textStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -158,7 +158,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val centerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val centerStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.CENTER verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -166,7 +166,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val intStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val intStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -176,7 +176,7 @@ fun generateStockLedgerReport( val df: DataFormat = workbook.createDataFormat() dataFormat = df.getFormat("#,##0") } - val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val dashStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -184,7 +184,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val sumQty = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumQty = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER val df: DataFormat = workbook.createDataFormat() @@ -196,7 +196,7 @@ fun generateStockLedgerReport( val font = workbook.createFont().apply { bold = true } setFont(font) } - val sumLabel = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumLabel = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THICK @@ -206,7 +206,7 @@ fun generateStockLedgerReport( val font = workbook.createFont().apply { bold = true } setFont(font) } - val sumEmpty = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumEmpty = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THICK @@ -214,7 +214,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val sumHidden = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumHidden = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THICK @@ -239,7 +239,7 @@ fun generateStockLedgerReport( ) } - private fun setTextCell(row: Row, col: Int, value: Any?, style: XSSFCellStyle) { + private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { row.createCell(col).apply { setCellValue(value?.toString() ?: "") cellStyle = style @@ -259,8 +259,8 @@ fun generateStockLedgerReport( row: Row, col: Int, value: Any?, - intStyle: XSSFCellStyle, - dashStyle: XSSFCellStyle, + intStyle: CellStyle, + dashStyle: CellStyle, ) { val cell = row.createCell(col) val parsed = parseSignedNumber(value) @@ -279,131 +279,160 @@ fun generateStockLedgerReport( } } + /** + * SXSSF keeps only a sliding window of rows in memory to avoid OOM on large exports. + */ private fun createStockLedgerExcel( dbData: List>, reportPeriodStart: String, reportPeriodEnd: String, ): ByteArray { - val workbook = XSSFWorkbook() - val styles = createStyles(workbook) - val reportTitle = "庫存明細報告" - val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) - - val headers = listOf( - "貨品編號", "貨品名稱", "單位", - "出入賬日期", "類型", "批號", "到期日", - "纍計期初存量", "入庫", "出庫", "纍計存量", - "參考編號", "存貨位置", - ) - val totalColumns = headers.size - var rowIndex = 0 + val workbook = SXSSFWorkbook(100) + workbook.setCompressTempFiles(true) + try { + val styles = createStyles(workbook) + val reportTitle = "庫存明細報告" + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) - val titleRow = sheet.createRow(rowIndex++) - titleRow.createCell(0).apply { - setCellValue(reportTitle) - cellStyle = styles.title - } - sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) - - val reportDateTime = - LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + - "(" + - LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + - ")" - val subtitleRow = sheet.createRow(rowIndex++) - subtitleRow.createCell(0).apply { - setCellValue("報告日期:$reportDateTime") - cellStyle = styles.subtitle - } - sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) - subtitleRow.createCell(5).apply { - setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") - cellStyle = styles.subtitle - } - sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) - sheet.createRow(rowIndex++) + val headers = listOf( + "貨品編號", "貨品名稱", "單位", + "出入賬日期", "類型", "批號", "到期日", + "纍計期初存量", "入庫", "出庫", "纍計存量", + "參考編號", "存貨位置", + ) + val totalColumns = headers.size + var rowIndex = 0 - val headerRowIndex = rowIndex - val headerRow = sheet.createRow(rowIndex++) - headers.forEachIndexed { i, h -> - headerRow.createCell(i).apply { - setCellValue(h) - cellStyle = styles.header + val titleRow = sheet.createRow(rowIndex++) + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = styles.title } - } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) - fun addItemSummaryRow(itemNo: String, itemName: String, uom: String, totalIn: Any?, totalOut: Any?, totalBal: Any?) { - val r = sheet.createRow(rowIndex++) - r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden } - r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden } - r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden } - for (c in 3 until totalColumns) { - r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty } + val reportDateTime = + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + "(" + + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + + ")" + val subtitleRow = sheet.createRow(rowIndex++) + subtitleRow.createCell(0).apply { + setCellValue("報告日期:$reportDateTime") + cellStyle = styles.subtitle } - // totals should align with numeric columns (shift right by 1) - r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel } - setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash) - setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash) - setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash) - } - - if (dbData.isEmpty()) { - val r = sheet.createRow(rowIndex++) - for (c in 0 until totalColumns) { - r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) + subtitleRow.createCell(5).apply { + setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") + cellStyle = styles.subtitle } - } else { - var currentItemNo: String? = null - var currentItemName = "" - var currentUom = "" - var lastTotals: Triple = Triple(null, null, null) + sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) + sheet.createRow(rowIndex++) - dbData.forEach { m -> - val itemNo = m["itemNo"]?.toString().orEmpty() - val itemName = m["itemName"]?.toString().orEmpty() - val uom = m["unitOfMeasure"]?.toString().orEmpty() + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = styles.header + } + } - if (currentItemNo != null && itemNo != currentItemNo) { - addItemSummaryRow(currentItemNo!!, currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third) - sheet.createRow(rowIndex++) + fun addItemSummaryRow( + itemNo: String, + itemName: String, + uom: String, + totalIn: Any?, + totalOut: Any?, + totalBal: Any?, + ) { + val r = sheet.createRow(rowIndex++) + r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden } + r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden } + r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden } + for (c in 3 until totalColumns) { + r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty } } + r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel } + setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash) + setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash) + setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash) + } + if (dbData.isEmpty()) { val r = sheet.createRow(rowIndex++) - setTextCell(r, 0, itemNo, styles.text) - setTextCell(r, 1, itemName, styles.text) - setTextCell(r, 2, uom, styles.center) - setTextCell(r, 3, m["trnDate"], styles.center) - val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t -> - if (t.equals("Expiry", ignoreCase = true)) "過期" else t + for (c in 0 until totalColumns) { + r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } } - setTextCell(r, 4, typeText, styles.center) - setTextCell(r, 5, m["lotNo"], styles.text) - setTextCell(r, 6, m["expiryDate"], styles.center) - setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash) - setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash) - setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash) - setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash) - setTextCell(r, 11, m["orderRefNo"], styles.text) - setTextCell(r, 12, m["storeLocation"], styles.center) + } else { + var currentItemNo: String? = null + var currentItemName = "" + var currentUom = "" + var lastTotals: Triple = Triple(null, null, null) + + dbData.forEach { m -> + val itemNo = m["itemNo"]?.toString().orEmpty() + val itemName = m["itemName"]?.toString().orEmpty() + val uom = m["unitOfMeasure"]?.toString().orEmpty() - currentItemNo = itemNo - currentItemName = itemName - currentUom = uom - lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"]) + if (currentItemNo != null && itemNo != currentItemNo) { + addItemSummaryRow( + currentItemNo!!, + currentItemName, + currentUom, + lastTotals.first, + lastTotals.second, + lastTotals.third, + ) + sheet.createRow(rowIndex++) + } + + val r = sheet.createRow(rowIndex++) + setTextCell(r, 0, itemNo, styles.text) + setTextCell(r, 1, itemName, styles.text) + setTextCell(r, 2, uom, styles.center) + setTextCell(r, 3, m["trnDate"], styles.center) + val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t -> + if (t.equals("Expiry", ignoreCase = true)) "過期" else t + } + setTextCell(r, 4, typeText, styles.center) + setTextCell(r, 5, m["lotNo"], styles.text) + setTextCell(r, 6, m["expiryDate"], styles.center) + setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash) + setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash) + setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash) + setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash) + setTextCell(r, 11, m["orderRefNo"], styles.text) + setTextCell(r, 12, m["storeLocation"], styles.center) + + currentItemNo = itemNo + currentItemName = itemName + currentUom = uom + lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"]) + } + + addItemSummaryRow( + currentItemNo ?: "", + currentItemName, + currentUom, + lastTotals.first, + lastTotals.second, + lastTotals.third, + ) } - addItemSummaryRow(currentItemNo ?: "", currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third) - } + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) + } + val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12) + widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } - val lastRowIndex = rowIndex - 1 - if (lastRowIndex >= headerRowIndex) { - sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } finally { + workbook.dispose() + workbook.close() } - val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12) - widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } - - val out = ByteArrayOutputStream() - workbook.use { it.write(out) } - return out.toByteArray() } -} \ No newline at end of file +} diff --git a/src/main/resources/jasper/StockLedgarReport.jrxml b/src/main/resources/jasper/StockLedgarReport.jrxml index 5bf97d7..a245642 100644 --- a/src/main/resources/jasper/StockLedgarReport.jrxml +++ b/src/main/resources/jasper/StockLedgarReport.jrxml @@ -79,7 +79,7 @@ - +