diff --git a/src/main/java/com/ffii/fpsms/m18/M18GrnRules.kt b/src/main/java/com/ffii/fpsms/m18/M18GrnRules.kt index 45cb98c..258aedb 100644 --- a/src/main/java/com/ffii/fpsms/m18/M18GrnRules.kt +++ b/src/main/java/com/ffii/fpsms/m18/M18GrnRules.kt @@ -3,7 +3,20 @@ package com.ffii.fpsms.m18 /** * M18 PO [createUid] is stored on [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder.m18CreatedUId]. * For these M18 user ids, FPSMS does not post GRN to M18 (see [com.ffii.fpsms.modules.stock.service.StockInLineService]). + * Remarks are shown on PO stock-in traceability report ([com.ffii.fpsms.modules.report.service.ReportService.searchStockInTraceabilityReport]). */ object M18GrnRules { - val SKIP_GRN_FOR_M18_CREATED_UIDS: Set = setOf(2569L, 2676L) + private val REMARKS_FOR_M18_CREATED_UID: Map = mapOf( + 2569L to "legato", + 2676L to "xtech", + ) + + val SKIP_GRN_FOR_M18_CREATED_UIDS: Set = REMARKS_FOR_M18_CREATED_UID.keys + + /** Display string for PDF/Excel: `2569 (legato)`, or plain id when unknown. */ + fun formatM18CreatedUidForReport(uid: Long?): String { + if (uid == null) return "" + val remark = REMARKS_FOR_M18_CREATED_UID[uid] + return if (remark != null) "$uid ($remark)" else uid.toString() + } } diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index 04dca22..81db7bf 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -9,12 +9,14 @@ import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SettingsResponse import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest +import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import com.ffii.fpsms.py.PrintedQtyByChannel import com.ffii.fpsms.py.PyJobOrderListItem import com.ffii.fpsms.py.PyJobOrderPrintSubmitService +import org.springframework.core.env.Environment import org.springframework.stereotype.Service import java.awt.Color import java.awt.Font @@ -28,6 +30,10 @@ import javax.imageio.ImageIO import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse import java.net.Socket import java.net.InetSocketAddress import java.io.PrintWriter @@ -41,6 +47,7 @@ import java.net.ConnectException import java.net.SocketTimeoutException import org.springframework.core.io.ClassPathResource import org.slf4j.LoggerFactory +import java.time.Duration import java.time.LocalDate // Data class to store bitmap bytes + width (for XML) @@ -53,6 +60,7 @@ class PlasticBagPrinterService( private val stockInLineRepository: StockInLineRepository, private val settingsService: SettingsService, private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, + private val environment: Environment, ) { private val logger = LoggerFactory.getLogger(javaClass) @@ -537,6 +545,59 @@ class PlasticBagPrinterService( return baos.toByteArray() } + /** + * Builds the same ZIP as [generateOnPackQrTextZip] and POSTs it to [ngpcl.push-url] (application/zip). + * When the URL is blank, returns [NgpclPushResponse] with pushed=false so callers can fall back to manual download. + */ + fun pushOnPackQrTextZipToNgpcl(jobOrders: List): NgpclPushResponse { + val url = (environment.getProperty("ngpcl.push-url") ?: "").trim() + if (url.isEmpty()) { + return NgpclPushResponse( + pushed = false, + message = "NGPCL push URL not configured. Set ngpcl.push-url or NGPCL_PUSH_URL, or download the ZIP and transfer loose files manually.", + ) + } + val zipBytes = try { + generateOnPackQrTextZip(jobOrders) + } catch (e: Exception) { + logger.warn("OnPack text ZIP generation failed before NGPCL push", e) + return NgpclPushResponse( + pushed = false, + message = e.message ?: "ZIP generation failed", + ) + } + return try { + val client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .build() + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofMinutes(2)) + .header("Content-Type", "application/zip") + .POST(HttpRequest.BodyPublishers.ofByteArray(zipBytes)) + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofString()) + val body = response.body() + if (response.statusCode() in 200..299) { + NgpclPushResponse( + pushed = true, + message = "NGPCL accepted (HTTP ${response.statusCode()})${if (body.isNotBlank()) ": ${body.take(300)}" else ""}", + ) + } else { + NgpclPushResponse( + pushed = false, + message = "NGPCL returned HTTP ${response.statusCode()}: ${body.take(500)}", + ) + } + } catch (e: Exception) { + logger.error("NGPCL push failed", e) + NgpclPushResponse( + pushed = false, + message = e.message ?: "Push failed: ${e.javaClass.simpleName}", + ) + } + } + /** * Returns uppercase item codes present in `onpack_qr` with the given [templateType] (`bmp` or `text`). * Empty or NULL `template_type` is treated as `bmp` for backward compatibility. diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt index cbe6879..8d99774 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt @@ -7,6 +7,7 @@ import com.ffii.fpsms.modules.jobOrder.web.model.Laser2Request import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SettingsResponse +import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrDownloadRequest import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusRequest import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusResponse @@ -152,6 +153,15 @@ class PlasticBagPrinterController( } } + /** + * Same payload as [downloadOnPackQrText], but POSTs the generated ZIP to `ngpcl.push-url` (application/zip). + * Returns JSON: `{ pushed, message }`. When push URL is unset, `pushed` is false — use download instead. + */ + @PostMapping("/ngpcl/push-onpack-qr-text") + fun pushOnPackQrTextToNgpcl(@RequestBody request: OnPackQrDownloadRequest): ResponseEntity { + return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders)) + } + /** * Test API to generate and download the printer job files as a ZIP. * ONPACK2030 diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt index c782060..eae5fd2 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt @@ -57,4 +57,10 @@ data class OnPackQrDownloadRequest( data class OnPackQrJobOrderRequest( val jobOrderId: Long, val itemCode: String, +) + +/** Result of POST /plastic/ngpcl/push-onpack-qr-text — server POSTs the lemon ZIP bytes to [ngpcl.push-url] when configured. */ +data class NgpclPushResponse( + val pushed: Boolean, + val message: String, ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 30e945b..b9c9d36 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -647,9 +647,10 @@ return result } /** - * Queries the database for Stock In Traceability Report data. + * Queries the database for Stock In Traceability Report data (PO 入倉 / 入倉追蹤 PDF). * Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables. * Supports comma-separated values for stockCategory (items.type) and itemCode. + * Adds `poM18CreatorDisplay` from `purchase_order.m18CreatedUId` via [M18GrnRules.formatM18CreatedUidForReport]. */ fun searchStockInTraceabilityReport( stockCategory: String?, @@ -704,6 +705,7 @@ return result COALESCE(wh.code, '') as storeLocation, COALESCE(sp_si.code, sp_po.code, '') as supplierID, COALESCE(sp_si.name, sp_po.name, '') as supplierName, + po.m18CreatedUId AS poM18CreatedUId, TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalStockInQty, TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcSampleQty FROM stock_in_line sil @@ -737,8 +739,21 @@ return result $lastInDateEndSql ORDER BY it.code, sil.lotNo """.trimIndent() - - return jdbcDao.queryForList(sql, args) + + val rows = jdbcDao.queryForList(sql, args) + return rows.map { row -> + val m = LinkedHashMap(row) + val raw = m.remove("poM18CreatedUId") + val uid = when (raw) { + null -> null + is Number -> raw.toLong() + is BigDecimal -> raw.toLong() + else -> raw.toString().toLongOrNull() + } + m["poM18CreatorDisplay"] = M18GrnRules.formatM18CreatedUidForReport(uid) + @Suppress("UNCHECKED_CAST") + m as Map + } } /** diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index deca0cb..56a7a83 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -50,6 +50,11 @@ jwt: logging: config: 'classpath:log4j2.yml' +# Optional NGPCL gateway: receives the same bytes as /plastic/download-onpack-qr-text (Content-Type: application/zip). +# Leave empty to disable; set NGPCL_PUSH_URL in production if you expose an HTTP receiver for the lemon OnPack ZIP. +ngpcl: + push-url: ${NGPCL_PUSH_URL:} + bom: import: temp-dir: ${java.io.tmpdir}/fpsms-bom-import diff --git a/src/main/resources/jasper/StockInTraceabilityReport.jrxml b/src/main/resources/jasper/StockInTraceabilityReport.jrxml index a3ce02c..6804432 100644 --- a/src/main/resources/jasper/StockInTraceabilityReport.jrxml +++ b/src/main/resources/jasper/StockInTraceabilityReport.jrxml @@ -57,6 +57,7 @@ + @@ -173,7 +174,7 @@ - + @@ -183,6 +184,18 @@ + + + + + + + + + + + @@ -471,7 +484,7 @@ - + @@ -479,6 +492,15 @@ + + + + + + + + +