From 2c6648ba14abfb7b3e61ca9fd10d8aa914d8b957 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Thu, 1 Oct 2026 20:15:40 +0800 Subject: [PATCH] fixing the around 16:30 logout problem; Handle user put DO (m18 PO) sync in m18 sync page PO tab; make the fg/semi fg report count by date rather than by month, created a button to make qc accept faster, added item code/name in search criteria of PO, make 2 new version of PO Item label printout (can set 1 to print the old version in setting) --- .../java/com/ffii/core/utils/QrCodeUtil.kt | 17 +- .../m18/service/M18DeliveryOrderService.kt | 68 +++-- .../m18/service/M18PurchaseOrderService.kt | 54 ++-- .../fpsms/modules/common/SettingNames.java | 3 + .../service/PurchaseOrderService.kt | 15 +- .../web/PurchaseOrderController.kt | 2 + .../SemiFGProductionAnalysisReportService.kt | 117 ++++++++ ...emiFGProductionAnalysisReportController.kt | 249 ++++++++++++++++++ .../stock/service/InventoryLotLineService.kt | 15 +- .../stock/service/PoItemLabelTemplate.kt | 63 +++++ .../stock/service/StockInLineService.kt | 15 +- src/main/resources/application-prod.yml | 5 +- src/main/resources/application.yml | 3 +- .../01_setting.sql | 9 + .../02_allow_version_3.sql | 7 + .../resources/qrCodeLabel/poItemPDF_v2.jrxml | 172 ++++++++++++ .../resources/qrCodeLabel/poItemPDF_v3.jrxml | 172 ++++++++++++ 17 files changed, 932 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt create mode 100644 src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql create mode 100644 src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql create mode 100644 src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml create mode 100644 src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml diff --git a/src/main/java/com/ffii/core/utils/QrCodeUtil.kt b/src/main/java/com/ffii/core/utils/QrCodeUtil.kt index 11f05544..e7b052f5 100644 --- a/src/main/java/com/ffii/core/utils/QrCodeUtil.kt +++ b/src/main/java/com/ffii/core/utils/QrCodeUtil.kt @@ -1,18 +1,21 @@ package com.ffii.core.utils import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType import java.awt.image.BufferedImage -import com.google.zxing.client.j2se.MatrixToImageWriter; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.client.j2se.MatrixToImageWriter +import com.google.zxing.qrcode.QRCodeWriter open class QrCodeUtil { companion object { fun generateQRCodeImage(barcodeText: String?): BufferedImage { - val barcodeWriter = QRCodeWriter() - val bitMatrix: BitMatrix = barcodeWriter.encode(barcodeText, BarcodeFormat.QR_CODE, 250, 250) - val img: BufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix) - return img + return generateQRCodeImage(barcodeText, 250, 4) + } + + fun generateQRCodeImage(barcodeText: String?, size: Int, margin: Int): BufferedImage { + val hints = hashMapOf(EncodeHintType.MARGIN to margin) + val bitMatrix = QRCodeWriter().encode(barcodeText, BarcodeFormat.QR_CODE, size, size, hints) + return MatrixToImageWriter.toBufferedImage(bitMatrix) } } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 56f6539e..6bd1d094 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt @@ -89,14 +89,8 @@ open class M18DeliveryOrderService( "" } - // Shop PO - val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=") - val shopPoSupplier = commonUtils.listToString( - shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER), - "venId=equal=", - "=or=" - ) - var shopPoConds = "(${shopPoBuyers})=and=(${shopPoSupplier})" + // Shop PO — same BE/supplier scope as [buildShopPoScopeConds] + var shopPoConds = buildShopPoScopeConds() if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { shopPoConds += "=and=(${lastDateConds})" @@ -160,9 +154,24 @@ open class M18DeliveryOrderService( ) } + /** + * Shop PO (MTMS DO) BE/supplier scope used by scheduler and by-code sync: + * beId = TOA and venId in [M18Config.SHOP_PO_SUPPLIER] (e.g. P06/P07). + */ + private fun buildShopPoScopeConds(): String { + val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=") + val shopPoSupplier = commonUtils.listToString( + shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER), + "venId=equal=", + "=or=" + ) + return "(${shopPoBuyers})=and=(${shopPoSupplier})" + } + /** * Sync a single M18 shop PO / delivery order by document [code], same search pattern as - * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. + * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode], including + * the scheduler shop-PO BE/supplier filters (rejects material POs). * * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). * No M18-side "加單" filtering is used. @@ -173,15 +182,16 @@ open class M18DeliveryOrderService( isExtraSync: Boolean = false, newOnly: Boolean = false, ): SyncResult { - if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { + val trimmed = code.trim() + if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(trimmed)) { return SyncResult( totalProcessed = 1, totalSuccess = 0, totalFail = 0, - query = "skipped (newOnly=true): delivery_order.code already exists: $code", + query = "skipped (newOnly=true): delivery_order.code already exists: $trimmed", ) } - val conds = "(code=equal=$code)" + val conds = "(code=equal=$trimmed)=and=${buildShopPoScopeConds()}" val searchRequest = M18PurchaseOrderListRequest( stSearch = "po", params = null, @@ -228,6 +238,7 @@ open class M18DeliveryOrderService( val successList = mutableListOf() val skippedList = mutableListOf() + val skippedNotPendingList = mutableListOf() val successDetailList = mutableListOf() val failList = mutableListOf() val failDetailList = mutableListOf() @@ -291,6 +302,23 @@ open class M18DeliveryOrderService( // logger.info("${doRefType}: Finding For Latest M18 Data Log...") val latestDeliveryOrderLog = m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType) + val existingDeliveryOrderForSync = + latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) } + if (existingDeliveryOrderForSync != null && + existingDeliveryOrderForSync.deleted != true && + existingDeliveryOrderForSync.status != DeliveryOrderStatus.PENDING + ) { + val statusValue = existingDeliveryOrderForSync.status?.value ?: "unknown" + logger.info( + "${doRefType}: Skipping M18 sync — local DO id=${existingDeliveryOrderForSync.id} " + + "code=${existingDeliveryOrderForSync.code} status=$statusValue " + + "(only pending may be overwritten; receiving=已放單). M18 ID: ${deliveryOrder.id}" + ) + skippedNotPendingList.add( + "code=${existingDeliveryOrderForSync.code} status=$statusValue" + ) + return@forEach + } //logger.info(latestDeliveryOrderLog.toString()) // Save to m18_data_log table // logger.info("${doRefType}: Saving for M18 Data Log...") @@ -312,10 +340,9 @@ open class M18DeliveryOrderService( // logger.info("${doRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLog.id}") try { - // Find the delivery_order if exist + // Find the delivery_order if exist (re-use lookup from pending guard above) // logger.info("${doRefType}: Finding exising delivery order...") - val existingDeliveryOrder = - latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) } + val existingDeliveryOrder = existingDeliveryOrderForSync // logger.info("${doRefType}: Exising delivery order ID: ${existingDeliveryOrder?.id}") // Save to delivery_order table @@ -621,12 +648,17 @@ open class M18DeliveryOrderService( logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") - val skippedSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" + val skippedExistingSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" + val skippedNotPendingSuffix = if (skippedNotPendingList.isNotEmpty()) { + " | skipped (not pending, only pending may be overwritten): ${skippedNotPendingList.joinToString("; ")}" + } else { + "" + } return SyncResult( - totalProcessed = successList.size + failList.size + skippedList.size, + totalProcessed = successList.size + failList.size + skippedList.size + skippedNotPendingList.size, totalSuccess = successList.size, totalFail = failList.size, - query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix, + query = (deliveryOrdersWithType?.query ?: "") + skippedExistingSuffix + skippedNotPendingSuffix, ) } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt index dd488c96..8dffa5ab 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt @@ -97,15 +97,8 @@ open class M18PurchaseOrderService( "" } - // Material PO - val materialPoBuyers = - commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=") - val materialPoSupplierNot = commonUtils.listToString( - shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT), - "venId=unequal=", - "=or=" - ) - var materialPoConds = "(${materialPoBuyers})=and=(${materialPoSupplierNot}))" + // Material PO — same BE/supplier scope as [buildMaterialPoScopeConds] + var materialPoConds = buildMaterialPoScopeConds() if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { materialPoConds += "=and=(${lastDateConds})" @@ -233,11 +226,28 @@ open class M18PurchaseOrderService( return if (beId?.toString() == m18Config.BEID_TOA) PurchaseOrderType.SHOP else PurchaseOrderType.MATERIAL } + /** + * Material PO BE/supplier scope used by scheduler and by-code sync: + * beId in (PP, PF) and venId not in [M18Config.MATERIAL_PO_SUPPLIER_NOT] (e.g. P06/P07 shop suppliers). + */ + private fun buildMaterialPoScopeConds(): String { + val materialPoBuyers = + commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=") + val materialPoSupplierNot = commonUtils.listToString( + shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT), + "venId=unequal=", + "=or=" + ) + return "(${materialPoBuyers})=and=(${materialPoSupplierNot})" + } + open fun savePurchaseOrderByCode(code: String): SyncResult { - // Follow the scheduler's original approach: - // 1) use /search/search to find the PO id by code - // 2) then sync by PO id (this reuses the same create/update logic) - val conds = "(code=equal=$code)" + // Same approach as scheduler list sync, restricted to one document code: + // 1) /search/search with material-PO BE/supplier scope + code + // 2) sync by PO id (reuses create/update logic) + // Rejects shop POs (TOA / P06|P07) that belong to DO sync instead. + val trimmed = code.trim() + val conds = "(code=equal=$trimmed)=and=${buildMaterialPoScopeConds()}" return saveFromPoListSearch(conds, searchPurchaseOrdersByConds(conds)) } @@ -357,6 +367,7 @@ open class M18PurchaseOrderService( val successDetailList = mutableListOf() val failList = mutableListOf() val failDetailList = mutableListOf() + val skippedNotPendingList = mutableListOf() val affectedItemIds = mutableSetOf() val uomByM18IdCache = mutableMapOf() @@ -398,11 +409,15 @@ open class M18PurchaseOrderService( if (existingPurchaseOrderForSync != null && existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING ) { + val statusValue = existingPurchaseOrderForSync.status?.value ?: "unknown" logger.info( "${poRefType}: Skipping M18 sync — local PO id=${existingPurchaseOrderForSync.id} " + - "code=${existingPurchaseOrderForSync.code} status=${existingPurchaseOrderForSync.status?.value} " + + "code=${existingPurchaseOrderForSync.code} status=$statusValue " + "(only pending may be overwritten). M18 ID: $m18PurchaseOrderId" ) + skippedNotPendingList.add( + "code=${existingPurchaseOrderForSync.code} status=$statusValue" + ) return@forEach } @@ -729,11 +744,18 @@ open class M18PurchaseOrderService( logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") + val baseQuery = purchaseOrdersWithType?.query ?: "" + val skippedSuffix = if (skippedNotPendingList.isNotEmpty()) { + " | skipped (not pending, only pending may be overwritten): ${skippedNotPendingList.joinToString("; ")}" + } else { + "" + } + return SyncResult( - totalProcessed = successList.size + failList.size, + totalProcessed = successList.size + failList.size + skippedNotPendingList.size, totalSuccess = successList.size, totalFail = failList.size, - query = purchaseOrdersWithType?.query ?: "" + query = baseQuery + skippedSuffix ) } } diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index f5e4bbae..7aad93fc 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -135,4 +135,7 @@ public abstract class SettingNames { /** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */ public static final String LASER_PRINT_LAST_RECEIVE_SUCCESS = "LASER_PRINT.lastReceiveSuccess"; + /** PO / stock-in item label: 1 = original, 2 = enlarged, 3 = smaller QR with a larger item name. */ + public static final String LABEL_PO_ITEM_VERSION = "LABEL.poItem.version"; + } diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt index 80a3e833..d90a7daa 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt @@ -211,8 +211,19 @@ open fun getPoSummariesByIds(ids: List): List { sql.append(" group by po.id"); sql.append(" order by po.orderDate desc") sql.append(" ) r") - if (args.containsKey("itemDetail")){ - sql.append(" where r.itemDetail like :itemDetail "); + val outerFilters = mutableListOf() + if (args.containsKey("itemDetail")) { + outerFilters.add("r.itemDetail like :itemDetail") + } + if (args.containsKey("itemCode")) { + outerFilters.add("r.itemCode like :itemCode") + } + if (args.containsKey("itemName")) { + outerFilters.add("r.itemName like :itemName") + } + if (outerFilters.isNotEmpty()) { + sql.append(" where ") + sql.append(outerFilters.joinToString(" AND ")) } return sql.toString() } diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt index 6e475304..24671530 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt @@ -47,6 +47,8 @@ class PurchaseOrderController( val criteriaArgs = CriteriaArgsBuilder.withRequest(request) .addStringLike("supplier") .addStringLike("code") + .addStringLike("itemCode") + .addStringLike("itemName") .addString("status") .addBoolean("escalated") .addDate("orderDate") diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt index bdcd3603..3d11430d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.report.service import org.springframework.stereotype.Service import com.ffii.core.support.JdbcDao +import java.time.LocalDate @Service class SemiFGProductionAnalysisReportService( @@ -199,6 +200,122 @@ class SemiFGProductionAnalysisReportService( return jdbcDao.queryForList(sql, args) } + /** + * Same filters as [searchSemiFGProductionAnalysisReport], one row per item and production date. + * Quantity is not pivoted; the Excel builder places each date in the requested range as its own column. + */ + fun searchSemiFGProductionAnalysisDaily( + stockCategory: String?, + stockSubCategory: String?, + itemCode: String?, + year: String?, + rangeStart: LocalDate, + rangeEnd: LocalDate, + ): List> { + val args = mutableMapOf() + + val stockCategorySql = if (!itemCode.isNullOrBlank()) { + "" + } else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { + buildMultiValueExactClause(stockCategory, "it.type", "semiSc", args) + } else { + "" + } + val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args) + val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "semiItem", args) + + val yearSql = if (!year.isNullOrBlank() && year != "All") { + args["year"] = year + "AND YEAR(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) = :year" + } else { + "" + } + + args["lastOutDateStart"] = rangeStart.toString() + args["lastOutDateEnd"] = rangeEnd.toString() + val lastOutDateStartSql = + "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) >= DATE(:lastOutDateStart)" + val lastOutDateEndSql = + "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) <= DATE(:lastOutDateEnd)" + + val sql = """ + WITH qr_agg AS ( + SELECT + qr.stockInLineId, + MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed + FROM qc_result qr + WHERE qr.deleted = 0 + GROUP BY qr.stockInLineId + ), + base AS ( + SELECT + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(ic.sub, '') AS stockSubCategory, + COALESCE(uc.udfudesc, '') AS unitOfMeasure, + DATE( + CASE + WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate + ELSE si.productionDate + END + ) AS prodDate, + si.id AS stockInLineId, + CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0 + ELSE COALESCE(si.acceptedQty, 0) + END AS linePutAwayQty + FROM stock_in_line si + INNER JOIN items it ON si.itemId = it.id + INNER JOIN bom b ON b.code = it.code AND b.deleted = false + LEFT JOIN inventory_lot il ON il.id = si.inventoryLotId AND il.deleted = false + LEFT JOIN qr_agg ON qr_agg.stockInLineId = si.id + LEFT JOIN item_category ic ON it.categoryId = ic.id + LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + WHERE si.deleted = false + AND si.productLotNo IS NOT NULL + AND TRIM(si.productLotNo) <> '' + AND ( + CASE + WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate + ELSE si.productionDate + END + ) IS NOT NULL + AND si.jobOrderId IS NOT NULL + AND (si.status IS NULL OR si.status <> 'Pending') + $stockCategorySql + $stockSubCategorySql + $itemCodeSql + $yearSql + $lastOutDateStartSql + $lastOutDateEndSql + ), + dedup AS ( + SELECT + itemNo, + itemName, + stockSubCategory, + unitOfMeasure, + prodDate, + stockInLineId, + MAX(linePutAwayQty) AS linePutAwayQty + FROM base + GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, prodDate, stockInLineId + ) + SELECT + d.itemNo AS itemNo, + MAX(d.itemName) AS itemName, + MAX(d.unitOfMeasure) AS unitOfMeasure, + d.prodDate AS prodDate, + CAST(COALESCE(SUM(d.linePutAwayQty), 0) AS DECIMAL(18,2)) AS qty + FROM dedup d + GROUP BY d.itemNo, d.prodDate + HAVING COALESCE(SUM(d.linePutAwayQty), 0) > 0 + ORDER BY d.itemNo, d.prodDate + """.trimIndent() + + return jdbcDao.queryForList(sql, args) + } + /** * Gets list of item codes (bom.code) with names based on stockCategory filter. * Supports multiple categories separated by comma (e.g., "FG,WIP"). diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt index 4182292e..7089ebd8 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt @@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit import com.ffii.fpsms.modules.report.service.SemiFGProductionAnalysisReportService import com.ffii.fpsms.modules.report.service.ReportService import org.apache.poi.ss.usermodel.BorderStyle @@ -173,6 +174,243 @@ class SemiFGProductionAnalysisReportController( return ResponseEntity(excelBytes, headers, HttpStatus.OK) } + @PostMapping("/print-semi-fg-production-analysis-excel-daily") + fun exportSemiFGProductionAnalysisReportExcelDaily( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + val start = parseReportDate(r.lastOutDateStart) + val end = parseReportDate(r.lastOutDateEnd) + if (start == null || end == null) { + return jsonError(HttpStatus.BAD_REQUEST, "請先輸入完成生產日期(由、至)") + } + if (end.isBefore(start)) { + return jsonError(HttpStatus.BAD_REQUEST, "完成生產日期「由」不可晚於「至」") + } + val dayCount = ChronoUnit.DAYS.between(start, end) + 1 + if (dayCount > MAX_DAILY_COLUMNS) { + return jsonError(HttpStatus.BAD_REQUEST, "日期範圍不可超過 $MAX_DAILY_COLUMNS 日") + } + + val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisDaily( + r.stockCategory, + r.stockSubCategory, + r.combinedItemCode(), + r.year, + start, + end, + ) + val dates = generateSequence(start) { previous -> + val next = previous.plusDays(1) + if (next.isAfter(end)) null else next + }.toList() + val excelBytes = createSemiFGProductionAnalysisDailyExcel( + dbData = dbData, + reportTitle = "成品/半成品生產分析報告", + dates = dates, + ) + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport_Daily.xlsx") + set("filename", "SemiFGProductionAnalysisReport_Daily.xlsx") + } + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createSemiFGProductionAnalysisDailyExcel( + dbData: List>, + reportTitle: String, + dates: List, + ): ByteArray { + val workbook = XSSFWorkbook() + val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle) + val sheet = workbook.createSheet(safeSheetName) + val dateFormat = DateTimeFormatter.ISO_LOCAL_DATE + val columnDateFormat = DateTimeFormatter.ofPattern( + if (dates.first().year == dates.last().year) "d/M" else "d/M/yy", + ) + val fixedColumns = 3 + val totalColumns = fixedColumns + dates.size + 1 + var rowIndex = 0 + + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + } + val titleFont = workbook.createFont().apply { + bold = true + fontHeightInPoints = 16 + } + titleStyle.setFont(titleFont) + + val infoStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + } + + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + wrapText = true + } + val headerFont = workbook.createFont().apply { bold = true } + headerStyle.setFont(headerFont) + + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") + } + + val dashStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val titleRow = sheet.createRow(rowIndex++) + titleRow.heightInPoints = 28f + val titleCell = titleRow.createCell(0) + titleCell.setCellValue(reportTitle) + titleCell.cellStyle = titleStyle + if (totalColumns > 1) { + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + } + + val rangeLabel = "${dates.first().format(dateFormat)} 至 ${dates.last().format(dateFormat)}" + val infoRow = sheet.createRow(rowIndex++) + val infoCell = infoRow.createCell(0) + infoCell.setCellValue( + "報告日期:${LocalDate.now().format(dateFormat)} " + + "報告時間:${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))} " + + "完成生產日期:$rangeLabel" + ) + infoCell.cellStyle = infoStyle + if (totalColumns > 1) { + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1)) + } + + rowIndex++ + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headerRow.heightInPoints = 32f + val headers = buildList { + add("貨品編號") + add("貨品名稱") + add("單位") + dates.forEach { add(it.format(columnDateFormat)) } + add("上架總計") + } + headers.forEachIndexed { index, name -> + val cell = headerRow.createCell(index) + cell.setCellValue(name) + cell.cellStyle = headerStyle + } + + val items = pivotDailyRows(dbData) + if (items.isEmpty()) { + val dataRow = sheet.createRow(rowIndex++) + setTextCell(dataRow, 0, "-", textStyle) + setTextCell(dataRow, 1, "-", textStyle) + setTextCell(dataRow, 2, "-", textStyle) + for (col in fixedColumns until totalColumns) { + setDashCell(dataRow, col, dashStyle) + } + } else { + items.forEach { item -> + val dataRow = sheet.createRow(rowIndex++) + setTextCell(dataRow, 0, item.itemNo.ifBlank { "-" }, textStyle) + setTextCell(dataRow, 1, item.itemName.ifBlank { "-" }, textStyle) + setTextCell(dataRow, 2, item.unitOfMeasure.ifBlank { "-" }, textStyle) + var total = 0.0 + dates.forEachIndexed { index, date -> + val qty = item.qtyByDate[date] ?: 0.0 + total += qty + setNumberCell(dataRow, fixedColumns + index, qty, numberStyle, dashStyle) + } + setNumberCell(dataRow, totalColumns - 1, total, numberStyle, dashStyle) + } + } + + sheet.createFreezePane(fixedColumns, headerRowIndex + 1) + sheet.setColumnWidth(0, 18 * 256) + sheet.setColumnWidth(1, 24 * 256) + sheet.setColumnWidth(2, 12 * 256) + for (col in fixedColumns until totalColumns) { + sheet.setColumnWidth(col, 12 * 256) + } + + val output = ByteArrayOutputStream() + workbook.use { it.write(output) } + return output.toByteArray() + } + + private fun pivotDailyRows(dbData: List>): List { + val byItem = linkedMapOf() + dbData.forEach { row -> + val itemNo = row["itemNo"]?.toString()?.trim().orEmpty() + val prodDate = toLocalDate(row["prodDate"]) ?: return@forEach + val qty = parseNumericForExcelChart(row["qty"]) + val item = byItem.getOrPut(itemNo) { + DailyItemQty( + itemNo = itemNo, + itemName = row["itemName"]?.toString().orEmpty(), + unitOfMeasure = row["unitOfMeasure"]?.toString().orEmpty(), + qtyByDate = linkedMapOf(), + ) + } + item.qtyByDate[prodDate] = (item.qtyByDate[prodDate] ?: 0.0) + qty + } + return byItem.values.toList() + } + + private fun parseReportDate(raw: String?): LocalDate? { + if (raw.isNullOrBlank()) return null + val normalized = raw.trim().replace("/", "-") + return runCatching { LocalDate.parse(normalized) }.getOrNull() + ?: runCatching { LocalDate.parse(normalized, DateTimeFormatter.ofPattern("dd-MM-yyyy")) }.getOrNull() + } + + private fun toLocalDate(value: Any?): LocalDate? = when (value) { + is LocalDate -> value + is java.sql.Date -> value.toLocalDate() + is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() + is java.time.LocalDateTime -> value.toLocalDate() + is java.util.Date -> value.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate() + is String -> parseReportDate(value.take(10)) + else -> null + } + + private fun jsonError(status: HttpStatus, message: String): ResponseEntity { + val escaped = message.replace("\\", "\\\\").replace("\"", "\\\"") + return ResponseEntity.status(status) + .contentType(MediaType.APPLICATION_JSON) + .body("""{"message":"$escaped"}""".toByteArray(Charsets.UTF_8)) + } + private fun createSemiFGProductionAnalysisExcel( dbData: List>, reportTitle: String, @@ -517,4 +755,15 @@ class SemiFGProductionAnalysisReportController( val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory) return ResponseEntity(itemCodesWithCategory, HttpStatus.OK) } + + private data class DailyItemQty( + val itemNo: String, + val itemName: String, + val unitOfMeasure: String, + val qtyByDate: MutableMap, + ) + + companion object { + private const val MAX_DAILY_COLUMNS = 366 + } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index 377e8b48..ec914f52 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -2,7 +2,6 @@ package com.ffii.fpsms.modules.stock.service import com.ffii.core.response.RecordsRes import com.ffii.core.utils.PdfUtils -import com.ffii.core.utils.QrCodeUtil import com.ffii.fpsms.modules.master.entity.ItemUomRespository import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.stock.entity.InventoryLotLine @@ -44,6 +43,7 @@ import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse import com.ffii.fpsms.modules.jobOrder.service.JobOrderService import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest import com.ffii.fpsms.modules.master.service.PrinterService +import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest import com.ffii.core.utils.ZebraPrinterUtil @@ -60,6 +60,7 @@ open class InventoryLotLineService( private val stockInLineRepository: StockInLineRepository, private val inventoryRepository: InventoryRepository, private val printerService: PrinterService, + private val settingsService: SettingsService, @Lazy private val jobOrderService: JobOrderService ) { @@ -247,7 +248,8 @@ open class InventoryLotLineService( @Throws(IOException::class) @Transactional open fun exportStockInLineQrcode(request: LotLineToQrcode): Map { - val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml" + val labelVersion = PoItemLabelTemplate.version(settingsService) + val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion) val resource = ClassPathResource(QRCODE_PDF) if (!resource.exists()) { println("here") @@ -287,13 +289,18 @@ open class InventoryLotLineService( field["acceptedQty"] = "%.2f".format(info.acceptedQty) val stockItemUom = itemUomRespository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(info.itemId) - field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc + field["uom"] = fullUom + val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) + field["qtyLabel"] = qtyLabel + field["qtyDetail"] = qtyDetail field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["lotNo"] = info.lotNo!! field["supplier"] = info.supplier ?: "N/A" - val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) + val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) field["qrCode"] = image fields.add(field) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt new file mode 100644 index 00000000..d0228bf0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt @@ -0,0 +1,63 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.core.utils.QrCodeUtil +import com.ffii.fpsms.modules.common.SettingNames +import com.ffii.fpsms.modules.settings.service.SettingsService +import java.awt.image.BufferedImage + +object PoItemLabelTemplate { + const val V1 = "qrCodeLabel/poItemPDF.jrxml" + const val V2 = "qrCodeLabel/poItemPDF_v2.jrxml" + const val V3 = "qrCodeLabel/poItemPDF_v3.jrxml" + + fun version(settingsService: SettingsService): Int { + val raw = settingsService.findByName(SettingNames.LABEL_PO_ITEM_VERSION) + .map { it.value } + .orElse("1") + ?.trim() + return when (raw) { + "2" -> 2 + "3" -> 3 + else -> 1 + } + } + + fun path(version: Int): String = when (version) { + 2 -> V2 + 3 -> V3 + else -> V1 + } + + fun qrImage(content: String, version: Int): BufferedImage = + if (version == 1) QrCodeUtil.generateQRCodeImage(content) + else QrCodeUtil.generateQRCodeImage(content, 500, 1) + + /** First line is qty + short unit. Second line is the full unit only when it differs. */ + fun qtyLines(qtyRaw: String?, fullRaw: String?, shortRaw: String?): Pair { + val qty = stripTrailingZeros(qtyRaw) + val full = blankUnit(fullRaw) + val short = blankUnit(shortRaw) + val same = short.isEmpty() || full.isEmpty() || short.equals(full, ignoreCase = true) + if (same) { + val unit = short.ifEmpty { full } + val line = when { + unit.isEmpty() -> qty + unit.first().isDigit() -> listOf(qty, unit).filter { it.isNotEmpty() }.joinToString(" ") + else -> qty + unit + } + return line to "" + } + return (qty + short) to full + } + + private fun blankUnit(raw: String?): String { + val text = raw?.trim().orEmpty() + return if (text.isEmpty() || text.equals("N/A", ignoreCase = true)) "" else text + } + + private fun stripTrailingZeros(raw: String?): String { + val text = raw?.trim().orEmpty() + if (!text.contains('.')) return text + return text.replace(Regex("0+$"), "").removeSuffix(".") + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index fa4c1abc..800dacfd 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -3,7 +3,6 @@ package com.ffii.fpsms.modules.stock.service import com.ffii.core.exception.BadRequestException import com.ffii.core.support.AbstractBaseEntityService import com.ffii.core.support.JdbcDao -import com.ffii.core.utils.QrCodeUtil import com.ffii.fpsms.modules.common.CodeGenerator import com.ffii.fpsms.modules.master.entity.ItemsRepository import com.ffii.fpsms.modules.master.entity.QcItemRepository @@ -33,6 +32,7 @@ import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.master.service.ItemUomService //import com.ffii.fpsms.modules.master.service.BomOutputQtyService import com.ffii.fpsms.modules.master.service.PrinterService +import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderRepository @@ -98,6 +98,7 @@ open class StockInLineService( private val itemUomRepository: ItemUomRespository, private val itemUomService: ItemUomService, private val printerService: PrinterService, + private val settingsService: SettingsService, private val stockTakeLineRepository: StockTakeLineRepository, private val inventoryLotLineService: InventoryLotLineService, private val deliveryOrderRepository: DeliveryOrderRepository, @@ -1208,7 +1209,8 @@ open class StockInLineService( @Throws(IOException::class) @Transactional open fun exportStockInLineQrcode(request: ExportQrCodeRequest): Map { - val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml" + val labelVersion = PoItemLabelTemplate.version(settingsService) + val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion) val resource = ClassPathResource(QRCODE_PDF) if (!resource.exists()) { println("here") @@ -1242,12 +1244,17 @@ open class StockInLineService( field["poCode"] = info.poCode ?: "N/A" field["itemType"] = info.itemType ?: "N/A" field["acceptedQty"] = "%.2f".format(acceptedQty) - field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc + field["uom"] = fullUom + val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) + field["qtyLabel"] = qtyLabel + field["qtyDetail"] = qtyDetail field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["lotNo"] = info.lotNo ?: "N/A" field["supplier"] = info.supplier ?: "N/A" - val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) + val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) field["qrCode"] = image fields.add(field) } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index dcd2d6e5..956ed653 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -3,9 +3,10 @@ fpsms: monitoring: enabled: true -# Session length in production; frontend can call /refresh-token before expiry to stay logged in. +# Session length in production; frontend refreshes via /refresh-token before expiry. +# Floor staff often work ~08:00–22:00 (~14h); 16h access covers one shift without mid-day kickout. jwt: - expiration-minutes: 480 # 8 hours access token (was 30 min); increase if users need longer AFK + expiration-minutes: 960 # 16 hours access token (was 480 / 8h — users logged out ~16:30 after morning login) refresh-expiration-days: 7 # Required for stable JWT across restarts; set JWT_SECRET in the deployment environment (long random string). secret: ${JWT_SECRET:fpsms-dev-jwt-signing-secret-change-for-production-use-long-random-JWT_SECRET} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74ce2bc6..ed67e727 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -113,8 +113,9 @@ spring: dialect: storage_engine: innodb -# JWT: access token expiry and refresh token expiry. Frontend should call /refresh-token before access token expires. +# JWT: access token expiry and refresh token expiry. Frontend calls /refresh-token before access token expires. # Signing key must be STABLE across server restarts (do not use a random key per boot). Override with env JWT_SECRET in production. +# Prod overrides to 16h access (floor shifts ~08:00–22:00); see application-prod.yml. jwt: expiration-minutes: 14400 # access token: 10 days (default); override in application-prod for shorter session refresh-expiration-days: 30 # refresh token validity (days) diff --git a/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql new file mode 100644 index 00000000..8af0d109 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql @@ -0,0 +1,9 @@ +--liquibase formatted sql +--changeset fpsms:20261001_po_item_label_version + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT 'LABEL.poItem.version', '1', 'LABEL', '1/2' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` WHERE `name` = 'LABEL.poItem.version' +); diff --git a/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql new file mode 100644 index 00000000..c704aa81 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql +--changeset fpsms:20261001_po_item_label_version_allow_3 + +UPDATE `settings` +SET `type` = '1/2/3' +WHERE `name` = 'LABEL.poItem.version' + AND `type` = '1/2'; diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml new file mode 100644 index 00000000..d4a57f3d --- /dev/null +++ b/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml new file mode 100644 index 00000000..47c4aec3 --- /dev/null +++ b/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +