| @@ -3,7 +3,20 @@ package com.ffii.fpsms.m18 | |||||
| /** | /** | ||||
| * M18 PO [createUid] is stored on [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder.m18CreatedUId]. | * 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]). | * 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 { | object M18GrnRules { | ||||
| val SKIP_GRN_FOR_M18_CREATED_UIDS: Set<Long> = setOf(2569L, 2676L) | |||||
| private val REMARKS_FOR_M18_CREATED_UID: Map<Long, String> = mapOf( | |||||
| 2569L to "legato", | |||||
| 2676L to "xtech", | |||||
| ) | |||||
| val SKIP_GRN_FOR_M18_CREATED_UIDS: Set<Long> = 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() | |||||
| } | |||||
| } | } | ||||
| @@ -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.LaserBag2SettingsResponse | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | 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.LaserRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest | import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest | ||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | import com.ffii.fpsms.modules.settings.service.SettingsService | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import com.ffii.fpsms.py.PrintedQtyByChannel | import com.ffii.fpsms.py.PrintedQtyByChannel | ||||
| import com.ffii.fpsms.py.PyJobOrderListItem | import com.ffii.fpsms.py.PyJobOrderListItem | ||||
| import com.ffii.fpsms.py.PyJobOrderPrintSubmitService | import com.ffii.fpsms.py.PyJobOrderPrintSubmitService | ||||
| import org.springframework.core.env.Environment | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.awt.Color | import java.awt.Color | ||||
| import java.awt.Font | import java.awt.Font | ||||
| @@ -28,6 +30,10 @@ import javax.imageio.ImageIO | |||||
| import com.google.zxing.BarcodeFormat | import com.google.zxing.BarcodeFormat | ||||
| import com.google.zxing.EncodeHintType | import com.google.zxing.EncodeHintType | ||||
| import com.google.zxing.qrcode.QRCodeWriter | 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.Socket | ||||
| import java.net.InetSocketAddress | import java.net.InetSocketAddress | ||||
| import java.io.PrintWriter | import java.io.PrintWriter | ||||
| @@ -41,6 +47,7 @@ import java.net.ConnectException | |||||
| import java.net.SocketTimeoutException | import java.net.SocketTimeoutException | ||||
| import org.springframework.core.io.ClassPathResource | import org.springframework.core.io.ClassPathResource | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import java.time.Duration | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| // Data class to store bitmap bytes + width (for XML) | // Data class to store bitmap bytes + width (for XML) | ||||
| @@ -53,6 +60,7 @@ class PlasticBagPrinterService( | |||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| private val settingsService: SettingsService, | private val settingsService: SettingsService, | ||||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | ||||
| private val environment: Environment, | |||||
| ) { | ) { | ||||
| private val logger = LoggerFactory.getLogger(javaClass) | private val logger = LoggerFactory.getLogger(javaClass) | ||||
| @@ -537,6 +545,59 @@ class PlasticBagPrinterService( | |||||
| return baos.toByteArray() | 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<OnPackQrJobOrderRequest>): 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`). | * 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. | * Empty or NULL `template_type` is treated as `bmp` for backward compatibility. | ||||
| @@ -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.LaserBag2SendRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse | 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.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.OnPackQrDownloadRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusRequest | import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusResponse | 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<NgpclPushResponse> { | |||||
| return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders)) | |||||
| } | |||||
| /** | /** | ||||
| * Test API to generate and download the printer job files as a ZIP. | * Test API to generate and download the printer job files as a ZIP. | ||||
| * ONPACK2030 | * ONPACK2030 | ||||
| @@ -57,4 +57,10 @@ data class OnPackQrDownloadRequest( | |||||
| data class OnPackQrJobOrderRequest( | data class OnPackQrJobOrderRequest( | ||||
| val jobOrderId: Long, | val jobOrderId: Long, | ||||
| val itemCode: String, | 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, | |||||
| ) | ) | ||||
| @@ -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. | * 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. | * Supports comma-separated values for stockCategory (items.type) and itemCode. | ||||
| * Adds `poM18CreatorDisplay` from `purchase_order.m18CreatedUId` via [M18GrnRules.formatM18CreatedUidForReport]. | |||||
| */ | */ | ||||
| fun searchStockInTraceabilityReport( | fun searchStockInTraceabilityReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| @@ -704,6 +705,7 @@ return result | |||||
| COALESCE(wh.code, '') as storeLocation, | COALESCE(wh.code, '') as storeLocation, | ||||
| COALESCE(sp_si.code, sp_po.code, '') as supplierID, | COALESCE(sp_si.code, sp_po.code, '') as supplierID, | ||||
| COALESCE(sp_si.name, sp_po.name, '') as supplierName, | 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 totalStockInQty, | ||||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcSampleQty | 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 | FROM stock_in_line sil | ||||
| @@ -737,8 +739,21 @@ return result | |||||
| $lastInDateEndSql | $lastInDateEndSql | ||||
| ORDER BY it.code, sil.lotNo | ORDER BY it.code, sil.lotNo | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | |||||
| val rows = jdbcDao.queryForList(sql, args) | |||||
| return rows.map { row -> | |||||
| val m = LinkedHashMap<String, Any?>(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<String, Any> | |||||
| } | |||||
| } | } | ||||
| /** | /** | ||||
| @@ -50,6 +50,11 @@ jwt: | |||||
| logging: | logging: | ||||
| config: 'classpath:log4j2.yml' | 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: | bom: | ||||
| import: | import: | ||||
| temp-dir: ${java.io.tmpdir}/fpsms-bom-import | temp-dir: ${java.io.tmpdir}/fpsms-bom-import | ||||
| @@ -57,6 +57,7 @@ | |||||
| <field name="storeLocation" class="java.lang.String"/> | <field name="storeLocation" class="java.lang.String"/> | ||||
| <field name="supplierID" class="java.lang.String"/> | <field name="supplierID" class="java.lang.String"/> | ||||
| <field name="supplierName" class="java.lang.String"/> | <field name="supplierName" class="java.lang.String"/> | ||||
| <field name="poM18CreatorDisplay" class="java.lang.String"/> | |||||
| <field name="totalStockInQty" class="java.lang.String"/> | <field name="totalStockInQty" class="java.lang.String"/> | ||||
| <field name="totalIqcSampleQty" class="java.lang.String"/> | <field name="totalIqcSampleQty" class="java.lang.String"/> | ||||
| <field name="totalIqcDefectQty" class="java.lang.String"/> | <field name="totalIqcDefectQty" class="java.lang.String"/> | ||||
| @@ -173,7 +174,7 @@ | |||||
| <text><![CDATA[送貨單編號]]></text> | <text><![CDATA[送貨單編號]]></text> | ||||
| </staticText> | </staticText> | ||||
| <staticText> | <staticText> | ||||
| <reportElement stretchType="RelativeToTallestObject" x="690" y="80" width="108" height="28" uuid="db5b9c55-0185-420b-ba6c-0e10d154cc8a"> | |||||
| <reportElement stretchType="RelativeToTallestObject" x="690" y="80" width="65" height="28" uuid="db5b9c55-0185-420b-ba6c-0e10d154cc8a"> | |||||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | <property name="com.jaspersoft.studio.unit.y" value="px"/> | ||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | <property name="com.jaspersoft.studio.unit.height" value="px"/> | ||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | <property name="com.jaspersoft.studio.unit.width" value="px"/> | ||||
| @@ -183,6 +184,18 @@ | |||||
| </textElement> | </textElement> | ||||
| <text><![CDATA[供應商名稱]]></text> | <text><![CDATA[供應商名稱]]></text> | ||||
| </staticText> | </staticText> | ||||
| <staticText> | |||||
| <reportElement stretchType="RelativeToTallestObject" x="758" y="80" width="42" height="28" uuid="a1b2c3d4-e5f6-7890-abcd-ef1234567890"> | |||||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="9"/> | |||||
| </textElement> | |||||
| <text><![CDATA[PO建立者 | |||||
| (M18)]]></text> | |||||
| </staticText> | |||||
| <staticText> | <staticText> | ||||
| <reportElement stretchType="RelativeToTallestObject" x="220" y="80" width="60" height="28" uuid="cd7a146a-1af0-4428-9b88-dcb159691656"> | <reportElement stretchType="RelativeToTallestObject" x="220" y="80" width="60" height="28" uuid="cd7a146a-1af0-4428-9b88-dcb159691656"> | ||||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | <property name="com.jaspersoft.studio.unit.y" value="px"/> | ||||
| @@ -471,7 +484,7 @@ | |||||
| <textFieldExpression><![CDATA[$F{storeLocation}]]></textFieldExpression> | <textFieldExpression><![CDATA[$F{storeLocation}]]></textFieldExpression> | ||||
| </textField> | </textField> | ||||
| <textField textAdjust="StretchHeight"> | <textField textAdjust="StretchHeight"> | ||||
| <reportElement x="690" y="2" width="108" height="18" uuid="eb6ed0fc-bfda-4a89-a163-fe08b00a0120"> | |||||
| <reportElement x="690" y="2" width="65" height="18" uuid="eb6ed0fc-bfda-4a89-a163-fe08b00a0120"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | <property name="com.jaspersoft.studio.unit.height" value="px"/> | ||||
| </reportElement> | </reportElement> | ||||
| <textElement textAlignment="Left" verticalAlignment="Top"> | <textElement textAlignment="Left" verticalAlignment="Top"> | ||||
| @@ -479,6 +492,15 @@ | |||||
| </textElement> | </textElement> | ||||
| <textFieldExpression><![CDATA[$F{supplierName}]]></textFieldExpression> | <textFieldExpression><![CDATA[$F{supplierName}]]></textFieldExpression> | ||||
| </textField> | </textField> | ||||
| <textField textAdjust="StretchHeight"> | |||||
| <reportElement x="758" y="2" width="42" height="18" uuid="f2e3d4c5-b6a7-8901-cdef-234567890abc"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Left" verticalAlignment="Top"> | |||||
| <font fontName="微軟正黑體" size="9"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$F{poM18CreatorDisplay}]]></textFieldExpression> | |||||
| </textField> | |||||
| </band> | </band> | ||||
| </detail> | </detail> | ||||
| </jasperReport> | </jasperReport> | ||||