| @@ -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<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.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<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`). | |||
| * 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.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<NgpclPushResponse> { | |||
| return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders)) | |||
| } | |||
| /** | |||
| * Test API to generate and download the printer job files as a ZIP. | |||
| * ONPACK2030 | |||
| @@ -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, | |||
| ) | |||
| @@ -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<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: | |||
| 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 | |||
| @@ -57,6 +57,7 @@ | |||
| <field name="storeLocation" class="java.lang.String"/> | |||
| <field name="supplierID" 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="totalIqcSampleQty" class="java.lang.String"/> | |||
| <field name="totalIqcDefectQty" class="java.lang.String"/> | |||
| @@ -173,7 +174,7 @@ | |||
| <text><![CDATA[送貨單編號]]></text> | |||
| </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.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -183,6 +184,18 @@ | |||
| </textElement> | |||
| <text><![CDATA[供應商名稱]]></text> | |||
| </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> | |||
| <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"/> | |||
| @@ -471,7 +484,7 @@ | |||
| <textFieldExpression><![CDATA[$F{storeLocation}]]></textFieldExpression> | |||
| </textField> | |||
| <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"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Left" verticalAlignment="Top"> | |||
| @@ -479,6 +492,15 @@ | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{supplierName}]]></textFieldExpression> | |||
| </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> | |||
| </detail> | |||
| </jasperReport> | |||