| @@ -36,3 +36,5 @@ out/ | |||||
| ### VS Code ### | ### VS Code ### | ||||
| .vscode/ | .vscode/ | ||||
| package-lock.json | package-lock.json | ||||
| python/Bag3.spec | |||||
| python/dist/Bag3.exe | |||||
| @@ -0,0 +1,9 @@ | |||||
| { | |||||
| "api_ip": "127.0.0.1", | |||||
| "api_port": "8090", | |||||
| "dabag_ip": "192.168.17.27", | |||||
| "dabag_port": "3008", | |||||
| "laser_ip": "192.168.18.68", | |||||
| "laser_port": "45678", | |||||
| "label_com": "TSC TTP-246M Pro" | |||||
| } | |||||
| @@ -2,6 +2,7 @@ py -m pip install pyinstaller | |||||
| py -m pip install --upgrade pyinstaller | py -m pip install --upgrade pyinstaller | ||||
| py -m PyInstaller --onefile --windowed --name "Bag1" Bag1.py | py -m PyInstaller --onefile --windowed --name "Bag1" Bag1.py | ||||
| py -m PyInstaller --onefile --windowed --name "Bag3" Bag3.py | |||||
| python -m pip install pyinstaller | python -m pip install pyinstaller | ||||
| python -m pip install --upgrade pyinstaller | python -m pip install --upgrade pyinstaller | ||||
| @@ -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() | |||||
| } | |||||
| } | } | ||||
| @@ -7,9 +7,12 @@ import java.time.LocalDateTime | |||||
| interface M18GoodsReceiptNoteLogRepository : AbstractRepository<M18GoodsReceiptNoteLog, Long> { | interface M18GoodsReceiptNoteLogRepository : AbstractRepository<M18GoodsReceiptNoteLog, Long> { | ||||
| /** Returns true if a successful GRN was already created for this PO (avoids core_201 duplicate). */ | |||||
| /** Returns true if a successful GRN was already created for this PO (legacy / broad check). */ | |||||
| fun existsByPurchaseOrderIdAndStatusTrue(purchaseOrderId: Long): Boolean | fun existsByPurchaseOrderIdAndStatusTrue(purchaseOrderId: Long): Boolean | ||||
| /** True if this stock-in line was already included in a successful M18 GRN (one delivery = one GRN batch). */ | |||||
| fun existsByStockInLineIdAndStatusTrue(stockInLineId: Long): Boolean | |||||
| /** | /** | ||||
| * GRN log rows that need M18 AN code backfill: have record id, no grn_code yet, | * GRN log rows that need M18 AN code backfill: have record id, no grn_code yet, | ||||
| * created in [start, end] inclusive (e.g. start = 4 days ago 00:00, end = now). | * created in [start, end] inclusive (e.g. start = 4 days ago 00:00, end = now). | ||||
| @@ -303,6 +303,45 @@ open class M18MasterDataService( | |||||
| } | } | ||||
| } | } | ||||
| /** Sync one product/material from M18 by item code (search list, then load line — same idea as PO/DO by code). */ | |||||
| open fun saveProductByCode(code: String): SyncResult { | |||||
| val trimmed = code.trim() | |||||
| if (trimmed.isEmpty()) { | |||||
| return SyncResult(totalProcessed = 1, totalSuccess = 0, totalFail = 1, query = "empty code") | |||||
| } | |||||
| ensureCunitSeededForAllIfEmpty() | |||||
| val fromLocal = itemsService.findByCode(trimmed)?.m18Id | |||||
| val m18Id = fromLocal ?: run { | |||||
| val conds = "(code=equal=$trimmed)" | |||||
| val listResponse = try { | |||||
| getList<M18ProductListResponse>( | |||||
| stSearch = StSearchType.PRODUCT.value, | |||||
| params = null, | |||||
| conds = conds, | |||||
| request = M18CommonRequest(), | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| logger.error("(saveProductByCode) M18 search failed: ${e.message}", e) | |||||
| null | |||||
| } | |||||
| listResponse?.values?.firstOrNull()?.id | |||||
| } | |||||
| if (m18Id == null) { | |||||
| return SyncResult( | |||||
| totalProcessed = 1, | |||||
| totalSuccess = 0, | |||||
| totalFail = 1, | |||||
| query = "code=equal=$trimmed", | |||||
| ) | |||||
| } | |||||
| val result = saveProduct(m18Id) | |||||
| return if (result != null) { | |||||
| SyncResult(totalProcessed = 1, totalSuccess = 1, totalFail = 0, query = "code=equal=$trimmed") | |||||
| } else { | |||||
| SyncResult(totalProcessed = 1, totalSuccess = 0, totalFail = 1, query = "code=equal=$trimmed") | |||||
| } | |||||
| } | |||||
| open fun saveProducts(request: M18CommonRequest): SyncResult { | open fun saveProducts(request: M18CommonRequest): SyncResult { | ||||
| logger.info("--------------------------------------------Start - Saving M18 Products / Materials--------------------------------------------") | logger.info("--------------------------------------------Start - Saving M18 Products / Materials--------------------------------------------") | ||||
| ensureCunitSeededForAllIfEmpty() | ensureCunitSeededForAllIfEmpty() | ||||
| @@ -74,6 +74,11 @@ class M18TestController ( | |||||
| fun testSyncDoByCode(@RequestParam code: String): SyncResult { | fun testSyncDoByCode(@RequestParam code: String): SyncResult { | ||||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code) | return m18DeliveryOrderService.saveDeliveryOrderByCode(code) | ||||
| } | } | ||||
| @GetMapping("/test/product-by-code") | |||||
| fun testSyncProductByCode(@RequestParam code: String): SyncResult { | |||||
| return m18MasterDataService.saveProductByCode(code) | |||||
| } | |||||
| // --------------------------------------------- Scheduler --------------------------------------------- /// | // --------------------------------------------- Scheduler --------------------------------------------- /// | ||||
| // @GetMapping("/schedule/po") | // @GetMapping("/schedule/po") | ||||
| // fun schedulePo(@RequestParam @Valid newCron: String) { | // fun schedulePo(@RequestParam @Valid newCron: String) { | ||||
| @@ -99,4 +99,7 @@ public abstract class SettingNames { | |||||
| /** Comma-separated BOM item codes shown on /laserPrint job list (e.g. PP1175); blank = no filter (all packaging JOs) */ | /** Comma-separated BOM item codes shown on /laserPrint job list (e.g. PP1175); blank = no filter (all packaging JOs) */ | ||||
| public static final String LASER_PRINT_ITEM_CODES = "LASER_PRINT.itemCodes"; | public static final String LASER_PRINT_ITEM_CODES = "LASER_PRINT.itemCodes"; | ||||
| /** 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"; | |||||
| } | } | ||||
| @@ -1360,7 +1360,11 @@ open class DeliveryOrderService( | |||||
| } | } | ||||
| params["deliveryNoteCode"] = doPickOrderRecord.deliveryNoteCode ?: "" | params["deliveryNoteCode"] = doPickOrderRecord.deliveryNoteCode ?: "" | ||||
| params["shopAddress"] = cartonLabelInfo[0].shopAddress ?: "" | params["shopAddress"] = cartonLabelInfo[0].shopAddress ?: "" | ||||
| params["shopName"] = doPickOrderRecord.shopName ?: cartonLabelInfo[0].shopName ?: "" | |||||
| val rawShopLabel = doPickOrderRecord.shopName ?: cartonLabelInfo[0].shopName ?: "" | |||||
| val parsedShopLabel = parseShopLabelForCartonLabel(rawShopLabel) | |||||
| params["shopCode"] = parsedShopLabel.shopCode | |||||
| params["shopCodeAbbr"] = parsedShopLabel.shopCodeAbbr | |||||
| params["shopName"] = parsedShopLabel.shopNameForLabel | |||||
| params["truckNo"] = doPickOrderRecord.truckLanceCode ?: "" | params["truckNo"] = doPickOrderRecord.truckLanceCode ?: "" | ||||
| for (cartonNumber in 1..request.numOfCarton) { | for (cartonNumber in 1..request.numOfCarton) { | ||||
| @@ -1374,6 +1378,44 @@ open class DeliveryOrderService( | |||||
| ) | ) | ||||
| } | } | ||||
| private data class ParsedShopLabelForCartonLabel( | |||||
| val shopCode: String, | |||||
| val shopCodeAbbr: String, | |||||
| val shopNameForLabel: String | |||||
| ) | |||||
| private fun parseShopLabelForCartonLabel(rawInput: String): ParsedShopLabelForCartonLabel { | |||||
| // Fixed input format: shopCode - shopName1-shopName2 | |||||
| val raw = rawInput.trim() | |||||
| val (shopCodePartRaw, restPart) = raw.split(" - ", limit = 2).let { parts -> | |||||
| (parts.getOrNull(0)?.trim().orEmpty()) to (parts.getOrNull(1)?.trim().orEmpty()) | |||||
| } | |||||
| val shopCode = shopCodePartRaw.let { code -> | |||||
| val trimmed = code.trim() | |||||
| if (trimmed.length > 5) trimmed.substring(0, 5) else trimmed | |||||
| } | |||||
| val (shopName1, shopName2) = restPart.split("-", limit = 2).let { parts -> | |||||
| (parts.getOrNull(0)?.trim().orEmpty()) to (parts.getOrNull(1)?.trim().orEmpty()) | |||||
| } | |||||
| val shopNameForLabel = if (shopName2.isNotBlank()) { | |||||
| "$shopName1\n$shopName2" | |||||
| } else { | |||||
| shopName1 | |||||
| } | |||||
| val shopCodeAbbr = if (shopCode.length >= 2) shopCode.substring(0, 2) else shopCode | |||||
| return ParsedShopLabelForCartonLabel( | |||||
| shopCode = shopCode, | |||||
| shopCodeAbbr = shopCodeAbbr, | |||||
| shopNameForLabel = shopNameForLabel | |||||
| ) | |||||
| } | |||||
| //Print Carton Labels | //Print Carton Labels | ||||
| @Transactional | @Transactional | ||||
| @@ -0,0 +1,42 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.scheduler | |||||
| import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.beans.factory.annotation.Value | |||||
| import org.springframework.scheduling.annotation.Scheduled | |||||
| import org.springframework.stereotype.Component | |||||
| import java.time.LocalDate | |||||
| /** | |||||
| * Periodically runs the same laser TCP send as /laserPrint (DB LASER_PRINT.host / port / itemCodes). | |||||
| * Disabled by default; set laser.bag2.auto-send.enabled=true. | |||||
| */ | |||||
| @Component | |||||
| class LaserBag2AutoSendScheduler( | |||||
| private val laserBag2AutoSendService: LaserBag2AutoSendService, | |||||
| @Value("\${laser.bag2.auto-send.enabled:false}") private val enabled: Boolean, | |||||
| @Value("\${laser.bag2.auto-send.limit-per-run:1}") private val limitPerRun: Int, | |||||
| ) { | |||||
| private val logger = LoggerFactory.getLogger(javaClass) | |||||
| @Scheduled(fixedRateString = "\${laser.bag2.auto-send.interval-ms:60000}") | |||||
| fun tick() { | |||||
| if (!enabled) { | |||||
| return | |||||
| } | |||||
| try { | |||||
| val report = laserBag2AutoSendService.runAutoSend( | |||||
| planStart = LocalDate.now(), | |||||
| limitPerRun = limitPerRun, | |||||
| ) | |||||
| logger.info( | |||||
| "Laser Bag2 scheduler: processed {}/{} job orders for {}", | |||||
| report.jobOrdersProcessed, | |||||
| report.jobOrdersFound, | |||||
| report.planStart, | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Laser Bag2 scheduler failed", e) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,114 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.service | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2AutoSendReport | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2JobSendResult | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | |||||
| import java.time.LocalDate | |||||
| /** | |||||
| * Finds packaging job orders for [planStart] using the same filter as [PlasticBagPrinterService.listLaserPrintJobOrders] | |||||
| * ([LASER_PRINT.itemCodes]), then sends Bag2-style laser TCP payloads via [PlasticBagPrinterService.sendLaserBag2Job], | |||||
| * which uses [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_HOST] / [LASER_PRINT_PORT] from the database. | |||||
| * | |||||
| * Matches /laserPrint row click: [sendsPerJob] rounds with [delayBetweenSendsMs] between rounds (default 3 × 3s like the frontend). | |||||
| */ | |||||
| @Service | |||||
| class LaserBag2AutoSendService( | |||||
| private val plasticBagPrinterService: PlasticBagPrinterService, | |||||
| ) { | |||||
| private val logger = LoggerFactory.getLogger(javaClass) | |||||
| companion object { | |||||
| /** Same as LaserPrint page (3 sends per row click). */ | |||||
| const val DEFAULT_SENDS_PER_JOB = 3 | |||||
| const val DEFAULT_DELAY_BETWEEN_SENDS_MS = 3000L | |||||
| } | |||||
| fun runAutoSend( | |||||
| planStart: LocalDate, | |||||
| limitPerRun: Int = 0, | |||||
| sendsPerJob: Int = DEFAULT_SENDS_PER_JOB, | |||||
| delayBetweenSendsMs: Long = DEFAULT_DELAY_BETWEEN_SENDS_MS, | |||||
| ): LaserBag2AutoSendReport { | |||||
| val (reachable, laserIp, laserPort) = plasticBagPrinterService.probeLaserBag2Tcp() | |||||
| if (!reachable) { | |||||
| logger.warn("Connection failed to the laser print: {} / {}", laserIp, laserPort) | |||||
| return LaserBag2AutoSendReport( | |||||
| planStart = planStart, | |||||
| jobOrdersFound = 0, | |||||
| jobOrdersProcessed = 0, | |||||
| results = emptyList(), | |||||
| ) | |||||
| } | |||||
| val orders = plasticBagPrinterService.listLaserPrintJobOrders(planStart) | |||||
| val toProcess = if (limitPerRun > 0) orders.take(limitPerRun) else orders | |||||
| val results = mutableListOf<LaserBag2JobSendResult>() | |||||
| logger.info( | |||||
| "Laser Bag2 auto-send: planStart={}, found={}, processing={}, sendsPerJob={}", | |||||
| planStart, | |||||
| orders.size, | |||||
| toProcess.size, | |||||
| sendsPerJob, | |||||
| ) | |||||
| for (jo in toProcess) { | |||||
| var lastMsg = "" | |||||
| var overallOk = true | |||||
| var lastPrinterAck: String? = null | |||||
| var lastReceiveAck = false | |||||
| for (attempt in 1..sendsPerJob) { | |||||
| val resp = plasticBagPrinterService.sendLaserBag2Job( | |||||
| LaserBag2SendRequest( | |||||
| itemId = jo.itemId, | |||||
| stockInLineId = jo.stockInLineId, | |||||
| itemCode = jo.itemCode, | |||||
| itemName = jo.itemName, | |||||
| jobOrderId = jo.id, | |||||
| jobOrderNo = jo.code, | |||||
| lotNo = jo.lotNo, | |||||
| source = "AUTO", | |||||
| ), | |||||
| ) | |||||
| lastMsg = resp.message | |||||
| lastPrinterAck = resp.printerAck | |||||
| lastReceiveAck = resp.receiveAcknowledged | |||||
| if (!resp.success) { | |||||
| overallOk = false | |||||
| logger.warn("Laser send failed jobOrderId={} attempt={}: {}", jo.id, attempt, resp.message) | |||||
| break | |||||
| } | |||||
| if (attempt < sendsPerJob) { | |||||
| try { | |||||
| Thread.sleep(delayBetweenSendsMs) | |||||
| } catch (e: InterruptedException) { | |||||
| Thread.currentThread().interrupt() | |||||
| overallOk = false | |||||
| lastMsg = "Interrupted" | |||||
| break | |||||
| } | |||||
| } | |||||
| } | |||||
| results.add( | |||||
| LaserBag2JobSendResult( | |||||
| jobOrderId = jo.id, | |||||
| itemCode = jo.itemCode, | |||||
| success = overallOk, | |||||
| message = lastMsg, | |||||
| printerAck = lastPrinterAck, | |||||
| receiveAcknowledged = lastReceiveAck, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| return LaserBag2AutoSendReport( | |||||
| planStart = planStart, | |||||
| jobOrdersFound = orders.size, | |||||
| jobOrdersProcessed = toProcess.size, | |||||
| results = results, | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -7,12 +7,17 @@ import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | |||||
| 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.LaserLastReceiveSuccessDto | |||||
| 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.PyJobOrderListItem | import com.ffii.fpsms.py.PyJobOrderListItem | ||||
| 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 | ||||
| @@ -26,6 +31,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 | ||||
| @@ -39,17 +48,32 @@ 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 com.fasterxml.jackson.databind.ObjectMapper | |||||
| import java.time.Duration | |||||
| import java.time.Instant | |||||
| 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) | ||||
| data class BitmapResult(val bytes: ByteArray, val width: Int) | data class BitmapResult(val bytes: ByteArray, val width: Int) | ||||
| /** One Bag2-style laser TCP attempt (internal to [PlasticBagPrinterService]). */ | |||||
| private data class LaserBag2TcpResult( | |||||
| val success: Boolean, | |||||
| val message: String, | |||||
| val payload: String, | |||||
| val printerAck: String?, | |||||
| val receiveAcknowledged: Boolean, | |||||
| ) | |||||
| @Service | @Service | ||||
| class PlasticBagPrinterService( | class PlasticBagPrinterService( | ||||
| val jobOrderRepository: JobOrderRepository, | val jobOrderRepository: JobOrderRepository, | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| private val settingsService: SettingsService, | private val settingsService: SettingsService, | ||||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | |||||
| private val environment: Environment, | |||||
| private val objectMapper: ObjectMapper, | |||||
| ) { | ) { | ||||
| private val logger = LoggerFactory.getLogger(javaClass) | private val logger = LoggerFactory.getLogger(javaClass) | ||||
| @@ -75,7 +99,43 @@ class PlasticBagPrinterService( | |||||
| val itemCodes = settingsService.findByName(SettingNames.LASER_PRINT_ITEM_CODES) | val itemCodes = settingsService.findByName(SettingNames.LASER_PRINT_ITEM_CODES) | ||||
| .map { it.value?.trim() ?: "" } | .map { it.value?.trim() ?: "" } | ||||
| .orElse(DEFAULT_LASER_ITEM_CODES) | .orElse(DEFAULT_LASER_ITEM_CODES) | ||||
| return LaserBag2SettingsResponse(host = host, port = port, itemCodes = itemCodes) | |||||
| return LaserBag2SettingsResponse( | |||||
| host = host, | |||||
| port = port, | |||||
| itemCodes = itemCodes, | |||||
| lastReceiveSuccess = readLaserLastReceiveSuccessFromSettings(), | |||||
| ) | |||||
| } | |||||
| private fun readLaserLastReceiveSuccessFromSettings(): LaserLastReceiveSuccessDto? { | |||||
| val raw = settingsService.findByName(SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS) | |||||
| .map { it.value } | |||||
| .orElse(null) | |||||
| ?.trim() | |||||
| .orEmpty() | |||||
| if (raw.isBlank() || raw == "{}") return null | |||||
| return try { | |||||
| val dto = objectMapper.readValue(raw, LaserLastReceiveSuccessDto::class.java) | |||||
| if (dto.sentAt.isNullOrBlank()) null else dto | |||||
| } catch (e: Exception) { | |||||
| logger.warn("Could not parse LASER_PRINT.lastReceiveSuccess: {}", e.message) | |||||
| null | |||||
| } | |||||
| } | |||||
| private fun persistLaserLastReceiveSuccess(request: LaserBag2SendRequest, printerAck: String?) { | |||||
| val dto = LaserLastReceiveSuccessDto( | |||||
| jobOrderId = request.jobOrderId, | |||||
| jobOrderNo = request.jobOrderNo?.trim()?.takeIf { it.isNotEmpty() }, | |||||
| lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() }, | |||||
| itemId = request.itemId, | |||||
| stockInLineId = request.stockInLineId, | |||||
| printerAck = printerAck?.trim()?.takeIf { it.isNotEmpty() }, | |||||
| sentAt = Instant.now().toString(), | |||||
| source = request.source?.trim()?.takeIf { it.isNotEmpty() } ?: "MANUAL", | |||||
| ) | |||||
| val json = objectMapper.writeValueAsString(dto) | |||||
| settingsService.update(SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS, json) | |||||
| } | } | ||||
| /** | /** | ||||
| @@ -108,7 +168,11 @@ class PlasticBagPrinterService( | |||||
| allowed.contains(code) | allowed.contains(code) | ||||
| } | } | ||||
| } | } | ||||
| return filtered.map { jo -> toPyJobOrderListItem(jo) } | |||||
| val ids = filtered.mapNotNull { it.id } | |||||
| val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | |||||
| return filtered.map { jo -> | |||||
| toPyJobOrderListItem(jo, printed[jo.id!!]) | |||||
| } | |||||
| } | } | ||||
| private fun parseLaserItemCodeFilters(raw: String?): Set<String> { | private fun parseLaserItemCodeFilters(raw: String?): Set<String> { | ||||
| @@ -119,13 +183,14 @@ class PlasticBagPrinterService( | |||||
| .toSet() | .toSet() | ||||
| } | } | ||||
| private fun toPyJobOrderListItem(jo: JobOrder): PyJobOrderListItem { | |||||
| private fun toPyJobOrderListItem(jo: JobOrder, printed: PrintedQtyByChannel?): PyJobOrderListItem { | |||||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | val itemCode = jo.bom?.item?.code ?: jo.bom?.code | ||||
| val itemName = jo.bom?.name ?: jo.bom?.item?.name | val itemName = jo.bom?.name ?: jo.bom?.item?.name | ||||
| val itemId = jo.bom?.item?.id | val itemId = jo.bom?.item?.id | ||||
| val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | ||||
| val stockInLineId = stockInLine?.id | val stockInLineId = stockInLine?.id | ||||
| val lotNo = stockInLine?.lotNo | val lotNo = stockInLine?.lotNo | ||||
| val p = printed ?: PrintedQtyByChannel() | |||||
| return PyJobOrderListItem( | return PyJobOrderListItem( | ||||
| id = jo.id!!, | id = jo.id!!, | ||||
| code = jo.code, | code = jo.code, | ||||
| @@ -136,6 +201,9 @@ class PlasticBagPrinterService( | |||||
| stockInLineId = stockInLineId, | stockInLineId = stockInLineId, | ||||
| itemId = itemId, | itemId = itemId, | ||||
| lotNo = lotNo, | lotNo = lotNo, | ||||
| bagPrintedQty = p.bagPrintedQty, | |||||
| labelPrintedQty = p.labelPrintedQty, | |||||
| laserPrintedQty = p.laserPrintedQty, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -154,22 +222,39 @@ class PlasticBagPrinterService( | |||||
| itemCode = request.itemCode, | itemCode = request.itemCode, | ||||
| itemName = request.itemName, | itemName = request.itemName, | ||||
| ) | ) | ||||
| if (first.first) { | |||||
| return LaserBag2SendResponse(success = true, message = first.second, payloadSent = first.third) | |||||
| val response = if (first.success) { | |||||
| LaserBag2SendResponse( | |||||
| success = true, | |||||
| message = first.message, | |||||
| payloadSent = first.payload, | |||||
| printerAck = first.printerAck, | |||||
| receiveAcknowledged = first.receiveAcknowledged, | |||||
| ) | |||||
| } else { | |||||
| val second = sendLaserBag2TcpOnce( | |||||
| ip = ip, | |||||
| port = port, | |||||
| itemId = request.itemId, | |||||
| stockInLineId = request.stockInLineId, | |||||
| itemCode = request.itemCode, | |||||
| itemName = request.itemName, | |||||
| ) | |||||
| LaserBag2SendResponse( | |||||
| success = second.success, | |||||
| message = second.message, | |||||
| payloadSent = second.payload, | |||||
| printerAck = second.printerAck, | |||||
| receiveAcknowledged = second.receiveAcknowledged, | |||||
| ) | |||||
| } | } | ||||
| val second = sendLaserBag2TcpOnce( | |||||
| ip = ip, | |||||
| port = port, | |||||
| itemId = request.itemId, | |||||
| stockInLineId = request.stockInLineId, | |||||
| itemCode = request.itemCode, | |||||
| itemName = request.itemName, | |||||
| ) | |||||
| return LaserBag2SendResponse( | |||||
| success = second.first, | |||||
| message = second.second, | |||||
| payloadSent = second.third, | |||||
| ) | |||||
| if (response.success && response.receiveAcknowledged) { | |||||
| try { | |||||
| persistLaserLastReceiveSuccess(request, response.printerAck) | |||||
| } catch (e: Exception) { | |||||
| logger.warn("Could not persist laser last receive success: {}", e.message) | |||||
| } | |||||
| } | |||||
| return response | |||||
| } | } | ||||
| private fun resolveLaserBag2Host(): String { | private fun resolveLaserBag2Host(): String { | ||||
| @@ -190,6 +275,17 @@ class PlasticBagPrinterService( | |||||
| return v.toIntOrNull() ?: DEFAULT_LASER_BAG2_PORT | return v.toIntOrNull() ?: DEFAULT_LASER_BAG2_PORT | ||||
| } | } | ||||
| /** | |||||
| * TCP connect probe to configured [LASER_PRINT.host] / [LASER_PRINT.port] (same endpoint as [sendLaserBag2Job] / auto-send). | |||||
| * @return Triple(ok, host, port) | |||||
| */ | |||||
| fun probeLaserBag2Tcp(): Triple<Boolean, String, Int> { | |||||
| val host = resolveLaserBag2Host() | |||||
| val port = resolveLaserBag2Port() | |||||
| val (ok, _) = checkTcpPrinter(host, port, "Laser") | |||||
| return Triple(ok, host, port) | |||||
| } | |||||
| private fun sendLaserBag2TcpOnce( | private fun sendLaserBag2TcpOnce( | ||||
| ip: String, | ip: String, | ||||
| port: Int, | port: Int, | ||||
| @@ -197,7 +293,7 @@ class PlasticBagPrinterService( | |||||
| stockInLineId: Long?, | stockInLineId: Long?, | ||||
| itemCode: String?, | itemCode: String?, | ||||
| itemName: String?, | itemName: String?, | ||||
| ): Triple<Boolean, String, String> { | |||||
| ): LaserBag2TcpResult { | |||||
| val codeStr = (itemCode ?: "").trim().replace(";", ",") | val codeStr = (itemCode ?: "").trim().replace(";", ",") | ||||
| val nameStr = (itemName ?: "").trim().replace(";", ",") | val nameStr = (itemName ?: "").trim().replace(";", ",") | ||||
| val payload = if (itemId != null && stockInLineId != null) { | val payload = if (itemId != null && stockInLineId != null) { | ||||
| @@ -214,27 +310,61 @@ class PlasticBagPrinterService( | |||||
| val out = socket.getOutputStream() | val out = socket.getOutputStream() | ||||
| out.write(bytes) | out.write(bytes) | ||||
| out.flush() | out.flush() | ||||
| // Half-close the write side so the peer sees a clean end-of-send before we read the ack. | |||||
| // Abrupt full socket close right after read often makes EZCAD log "Remote TCP client disconnected". | |||||
| try { | |||||
| socket.shutdownOutput() | |||||
| } catch (_: Exception) { | |||||
| } | |||||
| var ackRaw: String? = null | |||||
| var receiveAck = false | |||||
| socket.soTimeout = 500 | socket.soTimeout = 500 | ||||
| try { | try { | ||||
| val buf = ByteArray(4096) | val buf = ByteArray(4096) | ||||
| val n = socket.getInputStream().read(buf) | val n = socket.getInputStream().read(buf) | ||||
| if (n > 0) { | if (n > 0) { | ||||
| val ack = String(buf, 0, n, StandardCharsets.UTF_8).trim().lowercase() | |||||
| if (ack.contains("receive") && !ack.contains("invalid")) { | |||||
| return Triple(true, "已送出激光機:$payload(已確認)", payload) | |||||
| ackRaw = String(buf, 0, n, StandardCharsets.UTF_8).trim() | |||||
| val ackLower = ackRaw.lowercase() | |||||
| if (ackLower.contains("receive") && !ackLower.contains("invalid")) { | |||||
| receiveAck = true | |||||
| logger.info( | |||||
| "Laser TCP ack (receive): {}:{} sent={} ack={}", | |||||
| ip, | |||||
| port, | |||||
| payload, | |||||
| ackRaw, | |||||
| ) | |||||
| } else if (ackRaw.isNotEmpty()) { | |||||
| logger.info( | |||||
| "Laser TCP response: {}:{} sent={} ack={}", | |||||
| ip, | |||||
| port, | |||||
| payload, | |||||
| ackRaw, | |||||
| ) | |||||
| } | } | ||||
| } | } | ||||
| } catch (_: SocketTimeoutException) { | } catch (_: SocketTimeoutException) { | ||||
| // Same as Python: ignore read timeout, treat as sent | |||||
| // Same as Python Bag3: ignore read timeout, payload was still sent | |||||
| } | |||||
| val msg = if (receiveAck) { | |||||
| "已送出激光機:$payload(已確認)" | |||||
| } else { | |||||
| "已送出激光機:$payload" | |||||
| } | } | ||||
| return Triple(true, "已送出激光機:$payload", payload) | |||||
| return LaserBag2TcpResult(true, msg, payload, ackRaw, receiveAck) | |||||
| } catch (e: ConnectException) { | } catch (e: ConnectException) { | ||||
| return Triple(false, "無法連線至 $ip:$port,請確認激光機已開機且 IP 正確。", payload) | |||||
| return LaserBag2TcpResult(false, "無法連線至 $ip:$port,請確認激光機已開機且 IP 正確。", payload, null, false) | |||||
| } catch (e: SocketTimeoutException) { | } catch (e: SocketTimeoutException) { | ||||
| return Triple(false, "連線逾時 ($ip:$port),請檢查網路與連接埠。", payload) | |||||
| return LaserBag2TcpResult(false, "連線逾時 ($ip:$port),請檢查網路與連接埠。", payload, null, false) | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| return Triple(false, "激光機送出失敗:${e.message}", payload) | |||||
| return LaserBag2TcpResult(false, "激光機送出失敗:${e.message}", payload, null, false) | |||||
| } finally { | } finally { | ||||
| try { | |||||
| Thread.sleep(100) | |||||
| } catch (_: InterruptedException) { | |||||
| Thread.currentThread().interrupt() | |||||
| } | |||||
| try { | try { | ||||
| socket?.close() | socket?.close() | ||||
| } catch (_: Exception) { | } catch (_: Exception) { | ||||
| @@ -526,6 +656,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. | ||||
| @@ -759,7 +942,17 @@ class PlasticBagPrinterService( | |||||
| ): Pair<Boolean, String> { | ): Pair<Boolean, String> { | ||||
| return when (printerType.lowercase()) { | return when (printerType.lowercase()) { | ||||
| "dataflex" -> checkTcpPrinter(printerIp, printerPort ?: 3008, "DataFlex") | "dataflex" -> checkTcpPrinter(printerIp, printerPort ?: 3008, "DataFlex") | ||||
| "laser" -> checkTcpPrinter(printerIp, printerPort ?: 45678, "Laser") | |||||
| // - /bagPrint: sends browser localStorage laser IP (may differ from DB — second machine). | |||||
| // - /laserPrint: omit printerIp → use DB LASER_PRINT.* (same as sendLaserBag2Job). | |||||
| // - Explicit empty string → not configured (do not fall back to DB). | |||||
| "laser" -> { | |||||
| val trimmed = printerIp?.trim().orEmpty() | |||||
| when { | |||||
| trimmed.isNotEmpty() -> checkTcpPrinter(trimmed, printerPort ?: 45678, "Laser") | |||||
| printerIp == null -> checkTcpPrinter(resolveLaserBag2Host(), resolveLaserBag2Port(), "Laser") | |||||
| else -> false to "Laser IP is not configured" | |||||
| } | |||||
| } | |||||
| "label" -> { | "label" -> { | ||||
| val comPort = labelCom?.trim().orEmpty() | val comPort = labelCom?.trim().orEmpty() | ||||
| if (comPort.isBlank()) { | if (comPort.isBlank()) { | ||||
| @@ -781,14 +974,14 @@ class PlasticBagPrinterService( | |||||
| return try { | return try { | ||||
| Socket().use { socket -> | Socket().use { socket -> | ||||
| socket.connect(InetSocketAddress(ip, port), 3000) | socket.connect(InetSocketAddress(ip, port), 3000) | ||||
| true to "$printerName connected" | |||||
| true to "$printerName 已連線($ip:$port)" | |||||
| } | } | ||||
| } catch (e: SocketTimeoutException) { | } catch (e: SocketTimeoutException) { | ||||
| false to "$printerName connection timed out" | |||||
| false to "$printerName 連線逾時($ip:$port)" | |||||
| } catch (e: ConnectException) { | } catch (e: ConnectException) { | ||||
| false to "$printerName connection refused" | |||||
| false to "$printerName 無法連線($ip:$port)" | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| false to "$printerName connection failed: ${e.message}" | |||||
| false to "$printerName 連線失敗($ip:$port):${e.message}" | |||||
| } | } | ||||
| } | } | ||||
| @@ -1,12 +1,15 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.web | package com.ffii.fpsms.modules.jobOrder.web | ||||
| import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService | |||||
| import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService | import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService | ||||
| 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.Laser2Request | import com.ffii.fpsms.modules.jobOrder.web.model.Laser2Request | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2AutoSendReport | |||||
| 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 | ||||
| @@ -24,6 +27,7 @@ import org.slf4j.LoggerFactory | |||||
| @RequestMapping("/plastic") | @RequestMapping("/plastic") | ||||
| class PlasticBagPrinterController( | class PlasticBagPrinterController( | ||||
| private val plasticBagPrinterService: PlasticBagPrinterService, | private val plasticBagPrinterService: PlasticBagPrinterService, | ||||
| private val laserBag2AutoSendService: LaserBag2AutoSendService, | |||||
| ) { | ) { | ||||
| private val logger = LoggerFactory.getLogger(javaClass) | private val logger = LoggerFactory.getLogger(javaClass) | ||||
| @@ -58,6 +62,24 @@ class PlasticBagPrinterController( | |||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * Same as /laserPrint row workflow: list job orders for [planStart] filtered by LASER_PRINT.itemCodes, | |||||
| * then for each (up to [limitPerRun], 0 = all) send laser TCP commands using LASER_PRINT.host/port (3× with 3s gap per job). | |||||
| * For manual runs from /testing; scheduler uses [LaserBag2AutoSendScheduler]. | |||||
| */ | |||||
| @PostMapping("/laser-bag2-auto-send") | |||||
| fun runLaserBag2AutoSend( | |||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) planStart: LocalDate?, | |||||
| @RequestParam(required = false, defaultValue = "0") limitPerRun: Int, | |||||
| ): ResponseEntity<LaserBag2AutoSendReport> { | |||||
| val date = planStart ?: LocalDate.now() | |||||
| val report = laserBag2AutoSendService.runAutoSend( | |||||
| planStart = date, | |||||
| limitPerRun = limitPerRun, | |||||
| ) | |||||
| return ResponseEntity.ok(report) | |||||
| } | |||||
| @PostMapping("/check-printer") | @PostMapping("/check-printer") | ||||
| fun checkPrinter(@RequestBody request: PrinterStatusRequest): ResponseEntity<PrinterStatusResponse> { | fun checkPrinter(@RequestBody request: PrinterStatusRequest): ResponseEntity<PrinterStatusResponse> { | ||||
| val (connected, message) = plasticBagPrinterService.checkPrinterConnection( | val (connected, message) = plasticBagPrinterService.checkPrinterConnection( | ||||
| @@ -152,6 +174,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 | ||||
| @@ -0,0 +1,20 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.web.model | |||||
| import java.time.LocalDate | |||||
| /** Result of [com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService.runAutoSend]. */ | |||||
| data class LaserBag2AutoSendReport( | |||||
| val planStart: LocalDate, | |||||
| val jobOrdersFound: Int, | |||||
| val jobOrdersProcessed: Int, | |||||
| val results: List<LaserBag2JobSendResult>, | |||||
| ) | |||||
| data class LaserBag2JobSendResult( | |||||
| val jobOrderId: Long, | |||||
| val itemCode: String?, | |||||
| val success: Boolean, | |||||
| val message: String, | |||||
| val printerAck: String? = null, | |||||
| val receiveAcknowledged: Boolean = false, | |||||
| ) | |||||
| @@ -3,6 +3,9 @@ package com.ffii.fpsms.modules.jobOrder.web.model | |||||
| /** | /** | ||||
| * Body for Bag2.py-style laser TCP send: `json;itemCode;itemName;;` (UTF-8). | * Body for Bag2.py-style laser TCP send: `json;itemCode;itemName;;` (UTF-8). | ||||
| * Optional [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port]. | * Optional [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port]. | ||||
| * | |||||
| * Optional job metadata is used to persist [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS] | |||||
| * when the printer returns a receive ack. | |||||
| */ | */ | ||||
| data class LaserBag2SendRequest( | data class LaserBag2SendRequest( | ||||
| val itemId: Long? = null, | val itemId: Long? = null, | ||||
| @@ -11,4 +14,9 @@ data class LaserBag2SendRequest( | |||||
| val itemName: String? = null, | val itemName: String? = null, | ||||
| val printerIp: String? = null, | val printerIp: String? = null, | ||||
| val printerPort: Int? = null, | val printerPort: Int? = null, | ||||
| val jobOrderId: Long? = null, | |||||
| val jobOrderNo: String? = null, | |||||
| val lotNo: String? = null, | |||||
| /** AUTO (auto-send) or MANUAL (/laserPrint); optional. */ | |||||
| val source: String? = null, | |||||
| ) | ) | ||||
| @@ -4,4 +4,8 @@ data class LaserBag2SendResponse( | |||||
| val success: Boolean, | val success: Boolean, | ||||
| val message: String, | val message: String, | ||||
| val payloadSent: String? = null, | val payloadSent: String? = null, | ||||
| /** Raw bytes from the laser TCP peer after our payload (often `receive;;`). */ | |||||
| val printerAck: String? = null, | |||||
| /** True when [printerAck] contained `receive` and not `invalid` (same rule as Bag3.py). */ | |||||
| val receiveAcknowledged: Boolean = false, | |||||
| ) | ) | ||||
| @@ -2,9 +2,11 @@ package com.ffii.fpsms.modules.jobOrder.web.model | |||||
| /** | /** | ||||
| * @param itemCodes Comma-separated item codes for the laser job list filter (e.g. `PP1175` or `PP1175,AB123`). Empty string means no filter (show all packaging job orders). | * @param itemCodes Comma-separated item codes for the laser job list filter (e.g. `PP1175` or `PP1175,AB123`). Empty string means no filter (show all packaging job orders). | ||||
| * @param lastReceiveSuccess Last job where the laser printer returned a receive ack (from settings JSON); null if never recorded or empty. | |||||
| */ | */ | ||||
| data class LaserBag2SettingsResponse( | data class LaserBag2SettingsResponse( | ||||
| val host: String, | val host: String, | ||||
| val port: Int, | val port: Int, | ||||
| val itemCodes: String, | val itemCodes: String, | ||||
| val lastReceiveSuccess: LaserLastReceiveSuccessDto? = null, | |||||
| ) | ) | ||||
| @@ -0,0 +1,22 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.web.model | |||||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties | |||||
| /** | |||||
| * Persisted in [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS] when the laser | |||||
| * TCP peer returns a receive-style ack ([com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse.receiveAcknowledged]). | |||||
| */ | |||||
| @JsonIgnoreProperties(ignoreUnknown = true) | |||||
| data class LaserLastReceiveSuccessDto( | |||||
| val jobOrderId: Long? = null, | |||||
| /** Job order code (工單號). */ | |||||
| val jobOrderNo: String? = null, | |||||
| val lotNo: String? = null, | |||||
| val itemId: Long? = null, | |||||
| val stockInLineId: Long? = null, | |||||
| val printerAck: String? = null, | |||||
| /** ISO-8601 instant string (server UTC). */ | |||||
| val sentAt: String? = null, | |||||
| /** e.g. AUTO (scheduler/auto-send) or MANUAL (/laserPrint row). */ | |||||
| val source: String? = null, | |||||
| ) | |||||
| @@ -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,7 +647,7 @@ return result | |||||
| } | } | ||||
| /** | /** | ||||
| * Queries the database for Stock In Traceability Report data. | |||||
| * Queries the database for Stock In Traceability Report data (入倉追蹤 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. | ||||
| */ | */ | ||||
| @@ -737,7 +737,7 @@ return result | |||||
| $lastInDateEndSql | $lastInDateEndSql | ||||
| ORDER BY it.code, sil.lotNo | ORDER BY it.code, sil.lotNo | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| } | } | ||||
| @@ -793,7 +793,8 @@ return result | |||||
| MAX(ROUND(COALESCE(pol.up, 0) * COALESCE(sil.acceptedQty, 0), 2)) AS lineAmount, | MAX(ROUND(COALESCE(pol.up, 0) * COALESCE(sil.acceptedQty, 0), 2)) AS lineAmount, | ||||
| MAX(COALESCE(cur.code, '')) AS currencyCode, | MAX(COALESCE(cur.code, '')) AS currencyCode, | ||||
| MAX(grn.grn_code) AS grnCode, | MAX(grn.grn_code) AS grnCode, | ||||
| MAX(grn.m18_record_id) AS grnId | |||||
| MAX(grn.m18_record_id) AS grnId, | |||||
| MAX(po.m18CreatedUId) AS poM18CreatedUId | |||||
| FROM stock_in_line sil | FROM stock_in_line sil | ||||
| LEFT JOIN items it ON sil.itemId = it.id | LEFT JOIN items it ON sil.itemId = it.id | ||||
| LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id | LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id | ||||
| @@ -855,7 +856,15 @@ return result | |||||
| "lineAmount" to (row["lineAmount"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), | "lineAmount" to (row["lineAmount"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), | ||||
| "currencyCode" to row["currencyCode"], | "currencyCode" to row["currencyCode"], | ||||
| "grnCode" to row["grnCode"], | "grnCode" to row["grnCode"], | ||||
| "grnId" to row["grnId"] | |||||
| "grnId" to row["grnId"], | |||||
| "poM18CreatorDisplay" to M18GrnRules.formatM18CreatedUidForReport( | |||||
| when (val v = row["poM18CreatedUId"]) { | |||||
| null -> null | |||||
| is Number -> v.toLong() | |||||
| is BigDecimal -> v.toLong() | |||||
| else -> v.toString().toLongOrNull() | |||||
| }, | |||||
| ), | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @@ -80,6 +80,26 @@ fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine? | |||||
| """) | """) | ||||
| fun findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(@Param("purchaseOrderId") purchaseOrderId: Long): List<StockInLine> | fun findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(@Param("purchaseOrderId") purchaseOrderId: Long): List<StockInLine> | ||||
| /** Completed stock-in lines for this PO whose calendar receipt day matches (one M18 GRN per delivery date / DN batch). */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT DISTINCT sil FROM StockInLine sil | |||||
| LEFT JOIN FETCH sil.item | |||||
| LEFT JOIN FETCH sil.purchaseOrderLine pol | |||||
| LEFT JOIN FETCH pol.item | |||||
| WHERE sil.purchaseOrder.id = :purchaseOrderId | |||||
| AND sil.deleted = false | |||||
| AND sil.status = 'completed' | |||||
| AND sil.receiptDate IS NOT NULL | |||||
| AND DATE(sil.receiptDate) = :receiptDate | |||||
| ORDER BY sil.id | |||||
| """ | |||||
| ) | |||||
| fun findCompletedByPurchaseOrderIdAndReceiptDateAndDeletedFalseWithItemNames( | |||||
| @Param("purchaseOrderId") purchaseOrderId: Long, | |||||
| @Param("receiptDate") receiptDate: LocalDate, | |||||
| ): List<StockInLine> | |||||
| @Query(""" | @Query(""" | ||||
| SELECT sil FROM StockInLine sil | SELECT sil FROM StockInLine sil | ||||
| WHERE sil.receiptDate IS NOT NULL | WHERE sil.receiptDate IS NOT NULL | ||||
| @@ -15,6 +15,7 @@ import java.time.LocalDateTime | |||||
| /** | /** | ||||
| * Search completed stock-in lines (DN) by receipt date and process M18 GRN creation. | * Search completed stock-in lines (DN) by receipt date and process M18 GRN creation. | ||||
| * Query: receiptDate = yesterday, status = completed, purchaseOrderId not null. | * Query: receiptDate = yesterday, status = completed, purchaseOrderId not null. | ||||
| * Same PO may appear on multiple dates; each receipt date batch can produce its own M18 GRN. | |||||
| */ | */ | ||||
| @Service | @Service | ||||
| open class SearchCompletedDnService( | open class SearchCompletedDnService( | ||||
| @@ -82,7 +83,7 @@ open class SearchCompletedDnService( | |||||
| /** | /** | ||||
| * Post completed DNs and process each related purchase order for M18 GRN creation. | * Post completed DNs and process each related purchase order for M18 GRN creation. | ||||
| * Triggered by scheduler. One GRN per Purchase Order, with the PO lines it received (acceptedQty as ant qty). | |||||
| * Triggered by scheduler. One GRN per PO **per receipt date** for lines completed on that date (same PO may receive multiple GRNs on different days). | |||||
| * @param receiptDate Default: yesterday | * @param receiptDate Default: yesterday | ||||
| * @param skipFirst For testing/manual trigger: skip the first N POs. 1 = skip 1st, process from 2nd. 0 = process from 1st. | * @param skipFirst For testing/manual trigger: skip the first N POs. 1 = skip 1st, process from 2nd. 0 = process from 1st. | ||||
| * @param limitToFirst For testing/manual trigger: process only the next N POs after skip. 1 = one PO. null = all remaining POs. | * @param limitToFirst For testing/manual trigger: process only the next N POs after skip. 1 = one PO. null = all remaining POs. | ||||
| @@ -101,7 +102,7 @@ open class SearchCompletedDnService( | |||||
| toProcess.forEach { (poId, silList) -> | toProcess.forEach { (poId, silList) -> | ||||
| silList.firstOrNull()?.let { first -> | silList.firstOrNull()?.let { first -> | ||||
| try { | try { | ||||
| stockInLineService.processPurchaseOrderForGrn(first) | |||||
| stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate) | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| logger.error("[postCompletedDnAndProcessGrn] Failed for PO id=$poId: ${e.message}", e) | logger.error("[postCompletedDnAndProcessGrn] Failed for PO id=$poId: ${e.message}", e) | ||||
| } | } | ||||
| @@ -111,13 +112,9 @@ open class SearchCompletedDnService( | |||||
| } | } | ||||
| /** | /** | ||||
| * Retry GRN creation for completed stock-in lines where the PO does not have a SUCCESS log in | |||||
| * `m18_goods_receipt_note_log`. | |||||
| * | |||||
| * Grouping behavior matches normal scheduler: | |||||
| * - iterate by `receiptDate` day window | |||||
| * - for each day, group by `PO (purchaseOrderId)` | |||||
| * - process one GRN per PO using the first line from that PO group | |||||
| * Retry GRN creation for completed stock-in lines where **some** line in that PO batch (same receipt date) | |||||
| * has no SUCCESS row in `m18_goods_receipt_note_log` (matched by `stock_in_line_id`). | |||||
| * Same PO can receive multiple GRNs on different delivery/receipt dates. | |||||
| */ | */ | ||||
| @Transactional | @Transactional | ||||
| open fun postCompletedDnAndProcessGrnWithMissingRetry( | open fun postCompletedDnAndProcessGrnWithMissingRetry( | ||||
| @@ -172,9 +169,12 @@ open class SearchCompletedDnService( | |||||
| val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L } | val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L } | ||||
| val entries = byPo.entries.toList() | val entries = byPo.entries.toList() | ||||
| // Only retry POs that do NOT have any successful GRN log. | |||||
| val missingEntries = entries.filter { (poId, _) -> | |||||
| !m18GoodsReceiptNoteLogRepository.existsByPurchaseOrderIdAndStatusTrue(poId) | |||||
| // Per delivery date: retry if any completed line for this PO on this date lacks a successful GRN log. | |||||
| val missingEntries = entries.filter { (_, silList) -> | |||||
| silList.any { sil -> | |||||
| val id = sil.id ?: return@any true | |||||
| !m18GoodsReceiptNoteLogRepository.existsByStockInLineIdAndStatusTrue(id) | |||||
| } | |||||
| } | } | ||||
| val toProcess = missingEntries | val toProcess = missingEntries | ||||
| @@ -190,7 +190,7 @@ open class SearchCompletedDnService( | |||||
| toProcess.forEach { (poId, silList) -> | toProcess.forEach { (poId, silList) -> | ||||
| silList.firstOrNull()?.let { first -> | silList.firstOrNull()?.let { first -> | ||||
| try { | try { | ||||
| stockInLineService.processPurchaseOrderForGrn(first) | |||||
| stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate) | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| logger.error("[postCompletedDnAndProcessMissingGrnForReceiptDate] Failed for PO id=$poId: ${e.message}", e) | logger.error("[postCompletedDnAndProcessMissingGrnForReceiptDate] Failed for PO id=$poId: ${e.message}", e) | ||||
| } | } | ||||
| @@ -660,10 +660,10 @@ open class StockInLineService( | |||||
| /** | /** | ||||
| * Processes a purchase order for M18 GRN creation. Updates PO/line status and creates GRN if applicable. | * Processes a purchase order for M18 GRN creation. Updates PO/line status and creates GRN if applicable. | ||||
| * Called by SearchCompletedDnService (scheduler postCompletedDnAndProcessGrn) for batch processing of yesterday's completed DNs. | |||||
| * @param grnReceiptDate When set (scheduler paths), only completed lines on that calendar receipt day are included — separate deliveries get separate GRNs. | |||||
| */ | */ | ||||
| open fun processPurchaseOrderForGrn(stockInLine: StockInLine) { | |||||
| tryUpdatePurchaseOrderAndCreateGrnIfCompleted(stockInLine) | |||||
| open fun processPurchaseOrderForGrn(stockInLine: StockInLine, grnReceiptDate: LocalDate? = null) { | |||||
| tryUpdatePurchaseOrderAndCreateGrnIfCompleted(stockInLine, grnReceiptDate = grnReceiptDate) | |||||
| } | } | ||||
| /** | /** | ||||
| @@ -671,7 +671,10 @@ open class StockInLineService( | |||||
| * creates M18 Goods Receipt Note. Called after saving stock-in line for both | * creates M18 Goods Receipt Note. Called after saving stock-in line for both | ||||
| * RECEIVED and PENDING/ESCALATED status flows. | * RECEIVED and PENDING/ESCALATED status flows. | ||||
| */ | */ | ||||
| private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted(savedStockInLine: StockInLine) { | |||||
| private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted( | |||||
| savedStockInLine: StockInLine, | |||||
| grnReceiptDate: LocalDate? = null, | |||||
| ) { | |||||
| if (savedStockInLine.purchaseOrderLine == null) return | if (savedStockInLine.purchaseOrderLine == null) return | ||||
| val pol = savedStockInLine.purchaseOrderLine ?: return | val pol = savedStockInLine.purchaseOrderLine ?: return | ||||
| updatePurchaseOrderLineStatus(pol) | updatePurchaseOrderLineStatus(pol) | ||||
| @@ -682,12 +685,29 @@ open class StockInLineService( | |||||
| // Align POL.m18Lot with M18 before GRN (sourceLot must match M18 PO line lot or AN save may fail). | // Align POL.m18Lot with M18 before GRN (sourceLot must match M18 PO line lot or AN save may fail). | ||||
| syncPurchaseOrderLineM18LotFromM18(savedPo) | syncPurchaseOrderLineM18LotFromM18(savedPo) | ||||
| // Defensive: load only completed stock-in lines for the PO, so GRN payload can't include pending/escalated. | |||||
| val linesForGrn = stockInLineRepository.findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(savedPo.id!!) | |||||
| // Completed lines for GRN: either this receipt date only (multi-delivery POs) or all completed on PO (legacy). | |||||
| val linesForGrn = if (grnReceiptDate != null) { | |||||
| stockInLineRepository.findCompletedByPurchaseOrderIdAndReceiptDateAndDeletedFalseWithItemNames( | |||||
| savedPo.id!!, | |||||
| grnReceiptDate, | |||||
| ) | |||||
| } else { | |||||
| stockInLineRepository.findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(savedPo.id!!) | |||||
| } | |||||
| if (linesForGrn.isEmpty()) { | if (linesForGrn.isEmpty()) { | ||||
| logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") | logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") | ||||
| return | return | ||||
| } | } | ||||
| if (grnReceiptDate != null && linesForGrn.all { sil -> | |||||
| val id = sil.id ?: return@all false | |||||
| m18GoodsReceiptNoteLogRepository.existsByStockInLineIdAndStatusTrue(id) | |||||
| }) { | |||||
| logger.info( | |||||
| "[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] Skipping M18 GRN — all ${linesForGrn.size} line(s) for PO id=${savedPo.id} " + | |||||
| "code=${savedPo.code} on receipt date $grnReceiptDate already have successful GRN logs" | |||||
| ) | |||||
| return | |||||
| } | |||||
| if (savedPo.m18BeId == null || savedPo.supplier?.m18Id == null || savedPo.currency?.m18Id == null) { | if (savedPo.m18BeId == null || savedPo.supplier?.m18Id == null || savedPo.currency?.m18Id == null) { | ||||
| logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - missing M18 ids for PO id=${savedPo.id} code=${savedPo.code}. m18BeId=${savedPo.m18BeId}, supplier.m18Id=${savedPo.supplier?.m18Id}, currency.m18Id=${savedPo.currency?.m18Id}") | logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - missing M18 ids for PO id=${savedPo.id} code=${savedPo.code}. m18BeId=${savedPo.m18BeId}, supplier.m18Id=${savedPo.supplier?.m18Id}, currency.m18Id=${savedPo.currency?.m18Id}") | ||||
| return | return | ||||
| @@ -0,0 +1,8 @@ | |||||
| package com.ffii.fpsms.py | |||||
| /** Per–job-order cumulative printed qty, split by printer channel (not mixed). */ | |||||
| data class PrintedQtyByChannel( | |||||
| val bagPrintedQty: Long = 0, | |||||
| val labelPrintedQty: Long = 0, | |||||
| val laserPrintedQty: Long = 0, | |||||
| ) | |||||
| @@ -7,9 +7,13 @@ import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||||
| import org.springframework.format.annotation.DateTimeFormat | import org.springframework.format.annotation.DateTimeFormat | ||||
| import org.springframework.http.ResponseEntity | import org.springframework.http.ResponseEntity | ||||
| import org.springframework.web.bind.annotation.GetMapping | import org.springframework.web.bind.annotation.GetMapping | ||||
| import org.springframework.web.bind.annotation.PostMapping | |||||
| import org.springframework.web.bind.annotation.RequestBody | |||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RequestParam | import org.springframework.web.bind.annotation.RequestParam | ||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||
| import org.springframework.web.server.ResponseStatusException | |||||
| import org.springframework.http.HttpStatus | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| @@ -23,6 +27,7 @@ open class PyController( | |||||
| private val jobOrderRepository: JobOrderRepository, | private val jobOrderRepository: JobOrderRepository, | ||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| private val plasticBagPrinterService: PlasticBagPrinterService, | private val plasticBagPrinterService: PlasticBagPrinterService, | ||||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | |||||
| ) { | ) { | ||||
| companion object { | companion object { | ||||
| private const val PACKAGING_PROCESS_NAME = "包裝" | private const val PACKAGING_PROCESS_NAME = "包裝" | ||||
| @@ -46,10 +51,34 @@ open class PyController( | |||||
| dayEndExclusive, | dayEndExclusive, | ||||
| PACKAGING_PROCESS_NAME, | PACKAGING_PROCESS_NAME, | ||||
| ) | ) | ||||
| val list = orders.map { jo -> toListItem(jo) } | |||||
| val ids = orders.mapNotNull { it.id } | |||||
| val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | |||||
| val list = orders.map { jo -> | |||||
| toListItem(jo, printed[jo.id!!]) | |||||
| } | |||||
| return ResponseEntity.ok(list) | return ResponseEntity.ok(list) | ||||
| } | } | ||||
| /** | |||||
| * Record a print submit from Bag2 (e.g. 標簽機). No login. | |||||
| * POST /py/job-order-print-submit | |||||
| * Body: { "jobOrderId": 1, "qty": 10, "printChannel": "LABEL" | "DATAFLEX" | "LASER" } | |||||
| */ | |||||
| @PostMapping("/job-order-print-submit") | |||||
| open fun submitJobOrderPrint( | |||||
| @RequestBody body: PyJobOrderPrintSubmitRequest, | |||||
| ): ResponseEntity<PyJobOrderPrintSubmitResponse> { | |||||
| val channel = body.printChannel?.trim()?.takeIf { it.isNotEmpty() } ?: PyPrintChannel.LABEL | |||||
| if ( | |||||
| channel != PyPrintChannel.LABEL && | |||||
| channel != PyPrintChannel.DATAFLEX && | |||||
| channel != PyPrintChannel.LASER | |||||
| ) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported printChannel: $channel") | |||||
| } | |||||
| return ResponseEntity.ok(pyJobOrderPrintSubmitService.recordPrint(body.jobOrderId, body.qty, channel)) | |||||
| } | |||||
| /** | /** | ||||
| * Same as [listJobOrders] but filtered by system setting [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_ITEM_CODES] | * Same as [listJobOrders] but filtered by system setting [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_ITEM_CODES] | ||||
| * (comma-separated item codes). Public — no login (same as /py/job-orders). | * (comma-separated item codes). Public — no login (same as /py/job-orders). | ||||
| @@ -62,13 +91,14 @@ open class PyController( | |||||
| return ResponseEntity.ok(plasticBagPrinterService.listLaserPrintJobOrders(date)) | return ResponseEntity.ok(plasticBagPrinterService.listLaserPrintJobOrders(date)) | ||||
| } | } | ||||
| private fun toListItem(jo: JobOrder): PyJobOrderListItem { | |||||
| private fun toListItem(jo: JobOrder, printed: PrintedQtyByChannel?): PyJobOrderListItem { | |||||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | val itemCode = jo.bom?.item?.code ?: jo.bom?.code | ||||
| val itemName = jo.bom?.name ?: jo.bom?.item?.name | val itemName = jo.bom?.name ?: jo.bom?.item?.name | ||||
| val itemId = jo.bom?.item?.id | val itemId = jo.bom?.item?.id | ||||
| val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | ||||
| val stockInLineId = stockInLine?.id | val stockInLineId = stockInLine?.id | ||||
| val lotNo = stockInLine?.lotNo | val lotNo = stockInLine?.lotNo | ||||
| val p = printed ?: PrintedQtyByChannel() | |||||
| return PyJobOrderListItem( | return PyJobOrderListItem( | ||||
| id = jo.id!!, | id = jo.id!!, | ||||
| code = jo.code, | code = jo.code, | ||||
| @@ -79,6 +109,9 @@ open class PyController( | |||||
| stockInLineId = stockInLineId, | stockInLineId = stockInLineId, | ||||
| itemId = itemId, | itemId = itemId, | ||||
| lotNo = lotNo, | lotNo = lotNo, | ||||
| bagPrintedQty = p.bagPrintedQty, | |||||
| labelPrintedQty = p.labelPrintedQty, | |||||
| laserPrintedQty = p.laserPrintedQty, | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @@ -19,4 +19,10 @@ data class PyJobOrderListItem( | |||||
| val stockInLineId: Long?, | val stockInLineId: Long?, | ||||
| val itemId: Long?, | val itemId: Long?, | ||||
| val lotNo: String?, | val lotNo: String?, | ||||
| /** Cumulative qty from 打袋機 DataFlex submits (DATAFLEX). */ | |||||
| val bagPrintedQty: Long = 0, | |||||
| /** Cumulative qty from 標簽機 submits (LABEL). */ | |||||
| val labelPrintedQty: Long = 0, | |||||
| /** Cumulative qty from 激光機 submits (LASER). */ | |||||
| val laserPrintedQty: Long = 0, | |||||
| ) | ) | ||||
| @@ -0,0 +1,35 @@ | |||||
| package com.ffii.fpsms.py | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import com.ffii.fpsms.py.entity.PyJobOrderPrintSubmit | |||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.data.repository.query.Param | |||||
| interface PyJobOrderPrintSubmitRepository : AbstractRepository<PyJobOrderPrintSubmit, Long> { | |||||
| @Query( | |||||
| value = | |||||
| "SELECT job_order_id, COALESCE(SUM(qty), 0) FROM py_job_order_print_submit " + | |||||
| "WHERE deleted = 0 AND job_order_id IN (:ids) AND print_channel = :channel " + | |||||
| "GROUP BY job_order_id", | |||||
| nativeQuery = true, | |||||
| ) | |||||
| fun sumQtyGroupedByJobOrderId( | |||||
| @Param("ids") ids: List<Long>, | |||||
| @Param("channel") channel: String, | |||||
| ): List<Array<Any>> | |||||
| /** | |||||
| * One row per (job_order_id, print_channel) with summed qty. | |||||
| */ | |||||
| @Query( | |||||
| value = | |||||
| "SELECT job_order_id, print_channel, COALESCE(SUM(qty), 0) FROM py_job_order_print_submit " + | |||||
| "WHERE deleted = 0 AND job_order_id IN (:ids) " + | |||||
| "GROUP BY job_order_id, print_channel", | |||||
| nativeQuery = true, | |||||
| ) | |||||
| fun sumQtyGroupedByJobOrderIdAndChannel( | |||||
| @Param("ids") ids: List<Long>, | |||||
| ): List<Array<Any>> | |||||
| } | |||||
| @@ -0,0 +1,11 @@ | |||||
| package com.ffii.fpsms.py | |||||
| /** | |||||
| * POST /py/job-order-print-submit | |||||
| */ | |||||
| data class PyJobOrderPrintSubmitRequest( | |||||
| val jobOrderId: Long, | |||||
| val qty: Int, | |||||
| /** [PyPrintChannel.LABEL] | [PyPrintChannel.DATAFLEX] | [PyPrintChannel.LASER]; omit or blank → LABEL. */ | |||||
| val printChannel: String? = null, | |||||
| ) | |||||
| @@ -0,0 +1,9 @@ | |||||
| package com.ffii.fpsms.py | |||||
| data class PyJobOrderPrintSubmitResponse( | |||||
| val jobOrderId: Long, | |||||
| val submittedQty: Int, | |||||
| val printChannel: String, | |||||
| /** Cumulative printed qty for this job order and [printChannel] after this submit. */ | |||||
| val cumulativeQtyForChannel: Long, | |||||
| ) | |||||
| @@ -0,0 +1,72 @@ | |||||
| package com.ffii.fpsms.py | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | |||||
| import com.ffii.fpsms.py.entity.PyJobOrderPrintSubmit | |||||
| import org.springframework.http.HttpStatus | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import org.springframework.web.server.ResponseStatusException | |||||
| @Service | |||||
| open class PyJobOrderPrintSubmitService( | |||||
| private val repository: PyJobOrderPrintSubmitRepository, | |||||
| private val jobOrderRepository: JobOrderRepository, | |||||
| ) { | |||||
| /** | |||||
| * Cumulative printed qty per job order, split by channel (打袋 / 標籤 / 激光). | |||||
| */ | |||||
| open fun sumPrintedQtyByJobOrderIds(ids: List<Long>): Map<Long, PrintedQtyByChannel> { | |||||
| if (ids.isEmpty()) return emptyMap() | |||||
| val rows = repository.sumQtyGroupedByJobOrderIdAndChannel(ids) | |||||
| val out = mutableMapOf<Long, PrintedQtyByChannel>() | |||||
| for (row in rows) { | |||||
| val jobOrderId = (row[0] as Number).toLong() | |||||
| val channel = (row[1] as String).trim() | |||||
| val qty = (row[2] as Number).toLong() | |||||
| val cur = out.getOrDefault(jobOrderId, PrintedQtyByChannel()) | |||||
| out[jobOrderId] = | |||||
| when (channel) { | |||||
| PyPrintChannel.DATAFLEX -> cur.copy(bagPrintedQty = qty) | |||||
| PyPrintChannel.LABEL -> cur.copy(labelPrintedQty = qty) | |||||
| PyPrintChannel.LASER -> cur.copy(laserPrintedQty = qty) | |||||
| else -> cur | |||||
| } | |||||
| } | |||||
| return out | |||||
| } | |||||
| private fun sumPrintedByJobOrderIdsAndChannel(ids: List<Long>, channel: String): Map<Long, Long> { | |||||
| if (ids.isEmpty()) return emptyMap() | |||||
| val rows = repository.sumQtyGroupedByJobOrderId(ids, channel) | |||||
| return rows.associate { row -> | |||||
| (row[0] as Number).toLong() to (row[1] as Number).toLong() | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Persist one submit row and return cumulative print total for that job order and channel. | |||||
| */ | |||||
| @Transactional | |||||
| open fun recordPrint(jobOrderId: Long, qty: Int, printChannel: String): PyJobOrderPrintSubmitResponse { | |||||
| if (qty < 1) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "qty must be at least 1") | |||||
| } | |||||
| val jo = jobOrderRepository.findById(jobOrderId).orElseThrow { | |||||
| ResponseStatusException(HttpStatus.NOT_FOUND, "Job order not found: $jobOrderId") | |||||
| } | |||||
| val row = PyJobOrderPrintSubmit().apply { | |||||
| jobOrder = jo | |||||
| this.qty = qty | |||||
| this.printChannel = printChannel | |||||
| } | |||||
| repository.save(row) | |||||
| val total = sumPrintedByJobOrderIdsAndChannel(listOf(jobOrderId), printChannel)[jobOrderId] ?: qty.toLong() | |||||
| return PyJobOrderPrintSubmitResponse( | |||||
| jobOrderId = jobOrderId, | |||||
| submittedQty = qty, | |||||
| printChannel = printChannel, | |||||
| cumulativeQtyForChannel = total, | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,8 @@ | |||||
| package com.ffii.fpsms.py | |||||
| /** Values for [com.ffii.fpsms.py.entity.PyJobOrderPrintSubmit.printChannel]. */ | |||||
| object PyPrintChannel { | |||||
| const val LABEL = "LABEL" | |||||
| const val DATAFLEX = "DATAFLEX" | |||||
| const val LASER = "LASER" | |||||
| } | |||||
| @@ -0,0 +1,35 @@ | |||||
| package com.ffii.fpsms.py.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrder | |||||
| import jakarta.persistence.Column | |||||
| import jakarta.persistence.Entity | |||||
| import jakarta.persistence.FetchType | |||||
| import jakarta.persistence.JoinColumn | |||||
| import jakarta.persistence.ManyToOne | |||||
| import jakarta.persistence.Table | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Size | |||||
| /** | |||||
| * One row each time a Bag/py client submits a print quantity for a job order (per printer channel). | |||||
| * [printChannel] distinguishes 打袋 (DATAFLEX), 標籤 (LABEL), 激光 (LASER); cumulative [qty] per channel. | |||||
| */ | |||||
| @Entity | |||||
| @Table(name = "py_job_order_print_submit") | |||||
| open class PyJobOrderPrintSubmit : BaseEntity<Long>() { | |||||
| @NotNull | |||||
| @ManyToOne(fetch = FetchType.LAZY) | |||||
| @JoinColumn(name = "job_order_id", nullable = false, columnDefinition = "INT") | |||||
| open var jobOrder: JobOrder? = null | |||||
| @NotNull | |||||
| @Column(name = "qty", nullable = false) | |||||
| open var qty: Int? = null | |||||
| @Size(max = 32) | |||||
| @NotNull | |||||
| @Column(name = "print_channel", nullable = false, length = 32) | |||||
| open var printChannel: String? = null | |||||
| } | |||||
| @@ -1,166 +1,180 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | <?xml version="1.0" encoding="UTF-8"?> | ||||
| <!-- Created with Jaspersoft Studio version 6.21.3.final using JasperReports Library version 6.21.3-4a3078d20785ebe464f18037d738d12fc98c13cf --> | <!-- Created with Jaspersoft Studio version 6.21.3.final using JasperReports Library version 6.21.3-4a3078d20785ebe464f18037d738d12fc98c13cf --> | ||||
| <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="Blank_A4" pageWidth="425" pageHeight="283" columnWidth="385" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" uuid="baa9f270-b398-4f1c-b01e-ba216b7997e9"> | <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="Blank_A4" pageWidth="425" pageHeight="283" columnWidth="385" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" uuid="baa9f270-b398-4f1c-b01e-ba216b7997e9"> | ||||
| <property name="com.jaspersoft.studio.unit." value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.pageHeight" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.pageWidth" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.topMargin" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.bottomMargin" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.leftMargin" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.rightMargin" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.columnWidth" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.columnSpacing" value="pixel"/> | |||||
| <parameter name="shopPurchaseOrderNo" class="java.lang.String"/> | |||||
| <parameter name="shopName" class="java.lang.String"/> | |||||
| <parameter name="shopAddress" class="java.lang.String"/> | |||||
| <parameter name="deliveryNoteCode" class="java.lang.String"/> | |||||
| <parameter name="truckNo" class="java.lang.String"/> | |||||
| <queryString> | |||||
| <![CDATA[]]> | |||||
| </queryString> | |||||
| <background> | |||||
| <band splitType="Stretch"/> | |||||
| </background> | |||||
| <detail> | |||||
| <band height="243" splitType="Stretch"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| <textField> | |||||
| <reportElement x="140" y="180" width="240" height="30" uuid="8fac39f8-4936-43a5-8e1f-1afbc8ccca9c"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$P{shopPurchaseOrderNo}]]></textFieldExpression> | |||||
| </textField> | |||||
| <staticText> | |||||
| <reportElement x="0" y="180" width="140" height="30" uuid="e03fcb92-259c-4427-a68e-60fe5924d763"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[店鋪採購單編號: | |||||
| <property name="com.jaspersoft.studio.unit." value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.pageHeight" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.pageWidth" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.topMargin" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.bottomMargin" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.leftMargin" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.rightMargin" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.columnWidth" value="pixel"/> | |||||
| <property name="com.jaspersoft.studio.unit.columnSpacing" value="pixel"/> | |||||
| <parameter name="shopPurchaseOrderNo" class="java.lang.String"/> | |||||
| <parameter name="shopName" class="java.lang.String"/> | |||||
| <parameter name="shopAddress" class="java.lang.String"/> | |||||
| <parameter name="deliveryNoteCode" class="java.lang.String"/> | |||||
| <parameter name="truckNo" class="java.lang.String"/> | |||||
| <parameter name="shopCode" class="java.lang.String"/> | |||||
| <parameter name="shopCodeAbbr" class="java.lang.String"/> | |||||
| <queryString> | |||||
| <![CDATA[]]> | |||||
| </queryString> | |||||
| <background> | |||||
| <band splitType="Stretch"/> | |||||
| </background> | |||||
| <detail> | |||||
| <band height="243" splitType="Stretch"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| <textField> | |||||
| <reportElement x="142" y="210" width="240" height="30" uuid="8fac39f8-4936-43a5-8e1f-1afbc8ccca9c"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$P{shopPurchaseOrderNo}]]></textFieldExpression> | |||||
| </textField> | |||||
| <staticText> | |||||
| <reportElement x="2" y="210" width="140" height="30" uuid="e03fcb92-259c-4427-a68e-60fe5924d763"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[店鋪採購單編號: | |||||
| ]]></text> | ]]></text> | ||||
| </staticText> | |||||
| <staticText> | |||||
| <reportElement x="0" y="213" width="140" height="30" uuid="c8e417ed-73ce-4349-b83c-e59e25258544"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[箱數:]]></text> | |||||
| </staticText> | |||||
| <staticText> | |||||
| <reportElement x="0" y="150" width="140" height="30" uuid="f3ffd4ee-0513-41a5-94d7-f1fdb9966a76"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[送貨單編號:]]></text> | |||||
| </staticText> | |||||
| <textField> | |||||
| <reportElement x="140" y="150" width="240" height="30" uuid="4319059b-9096-4c49-8275-287be93d3e6a"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$P{deliveryNoteCode}]]></textFieldExpression> | |||||
| </textField> | |||||
| <textField textAdjust="ScaleFont"> | |||||
| <reportElement x="0" y="0" width="380" height="40" uuid="9a440925-1bd4-4001-9b4b-7163ac27551e"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="18" isBold="true" isUnderline="false"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$P{shopName}]]></textFieldExpression> | |||||
| </textField> | |||||
| <textField textAdjust="ScaleFont"> | |||||
| <reportElement x="0" y="40" width="380" height="70" uuid="26b2c156-341b-4f59-abce-bd84ea000d9d"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Top"> | |||||
| <font fontName="微軟正黑體" size="16" isBold="true"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$P{shopAddress}]]></textFieldExpression> | |||||
| </textField> | |||||
| <staticText> | |||||
| <reportElement x="255" y="213" width="35" height="30" uuid="7467bc85-22c2-4b2a-bed0-a2e82a5dba6d"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[箱]]></text> | |||||
| </staticText> | |||||
| <textField> | |||||
| <reportElement x="140" y="213" width="40" height="30" uuid="e340a673-9fdc-4559-8431-af8ea391c472"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||||
| <font size="16"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression> | |||||
| </textField> | |||||
| <textField evaluationTime="Report"> | |||||
| <reportElement x="215" y="213" width="40" height="30" uuid="89ccad73-0571-4291-ae26-7804925d47eb"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||||
| <font size="16"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression> | |||||
| </textField> | |||||
| <staticText> | |||||
| <reportElement x="180" y="213" width="35" height="30" uuid="1cdd7507-299b-406b-baab-d2c23e44eeb0"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[/]]></text> | |||||
| </staticText> | |||||
| <staticText> | |||||
| <reportElement x="0" y="120" width="140" height="30" uuid="c8b9fafb-9e8b-479f-9a9f-dadda7854f95"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[貨車班次: | |||||
| </staticText> | |||||
| <staticText> | |||||
| <reportElement x="2" y="180" width="140" height="30" uuid="f3ffd4ee-0513-41a5-94d7-f1fdb9966a76"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[送貨單編號:]]></text> | |||||
| </staticText> | |||||
| <textField> | |||||
| <reportElement x="142" y="180" width="240" height="30" uuid="4319059b-9096-4c49-8275-287be93d3e6a"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$P{deliveryNoteCode}]]></textFieldExpression> | |||||
| </textField> | |||||
| <textField textAdjust="ScaleFont"> | |||||
| <reportElement x="0" y="0" width="220" height="50" uuid="9a440925-1bd4-4001-9b4b-7163ac27551e"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Left" verticalAlignment="Top"> | |||||
| <font fontName="微軟正黑體" size="38" isBold="true" isUnderline="false"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$P{shopCode}]]></textFieldExpression> | |||||
| </textField> | |||||
| <staticText> | |||||
| <reportElement x="2" y="150" width="140" height="30" uuid="c8b9fafb-9e8b-479f-9a9f-dadda7854f95"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[貨車班次: | |||||
| ]]></text> | ]]></text> | ||||
| </staticText> | |||||
| <textField> | |||||
| <reportElement x="140" y="120" width="240" height="30" uuid="57f8e4fa-cea0-42c5-b9e5-a33f0a2710b8"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16" isBold="true"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$P{truckNo}]]></textFieldExpression> | |||||
| </textField> | |||||
| <line> | |||||
| <reportElement x="0" y="110" width="380" height="1" uuid="3e37c027-d6e9-4a88-b64d-58ba1dd3b22e"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| </line> | |||||
| </band> | |||||
| </detail> | |||||
| </staticText> | |||||
| <textField> | |||||
| <reportElement x="142" y="150" width="240" height="30" uuid="57f8e4fa-cea0-42c5-b9e5-a33f0a2710b8"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16" isBold="true"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$P{truckNo}]]></textFieldExpression> | |||||
| </textField> | |||||
| <line> | |||||
| <reportElement x="2" y="140" width="380" height="1" uuid="3e37c027-d6e9-4a88-b64d-58ba1dd3b22e"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| </line> | |||||
| <textField textAdjust="ScaleFont"> | |||||
| <reportElement x="230" y="0" width="152" height="99" uuid="ed6f8ce7-e351-4eeb-9f95-49d64e7ed2dd"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| </reportElement> | |||||
| <box> | |||||
| <pen lineWidth="4.0"/> | |||||
| </box> | |||||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="100" isBold="true" isUnderline="false"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$P{shopCodeAbbr}]]></textFieldExpression> | |||||
| </textField> | |||||
| <textField textAdjust="ScaleFont"> | |||||
| <reportElement x="0" y="50" width="220" height="49" uuid="75a47bc6-5830-4636-9c62-1285163bf0b6"> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Left" verticalAlignment="Top"> | |||||
| <font fontName="微軟正黑體" size="22" isBold="true" isUnderline="false"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$P{shopName}]]></textFieldExpression> | |||||
| </textField> | |||||
| <staticText> | |||||
| <reportElement x="2" y="106" width="140" height="30" uuid="0ccaeebc-681b-449e-b547-97fc86c35662"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[箱數:]]></text> | |||||
| </staticText> | |||||
| <staticText> | |||||
| <reportElement x="262" y="106" width="120" height="30" uuid="05bc180b-a58d-4ad8-95f6-bc3090ee2c2d"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[ 箱(蛋類除外)]]></text> | |||||
| </staticText> | |||||
| <textField> | |||||
| <reportElement x="142" y="106" width="40" height="30" uuid="dab335a5-e253-498c-a9bf-b9707d8e1099"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||||
| <font size="16"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression> | |||||
| </textField> | |||||
| <textField evaluationTime="Report"> | |||||
| <reportElement x="217" y="106" width="40" height="30" uuid="66d50bad-7b39-49c1-b127-4d763133ee0c"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||||
| <font size="16"/> | |||||
| </textElement> | |||||
| <textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression> | |||||
| </textField> | |||||
| <staticText> | |||||
| <reportElement x="182" y="106" width="35" height="30" uuid="73c18ae5-a07b-4215-a753-fa72d6db87eb"> | |||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||||
| </reportElement> | |||||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||||
| <font fontName="微軟正黑體" size="16"/> | |||||
| </textElement> | |||||
| <text><![CDATA[/]]></text> | |||||
| </staticText> | |||||
| </band> | |||||
| </detail> | |||||
| </jasperReport> | </jasperReport> | ||||
| @@ -50,6 +50,20 @@ 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:} | |||||
| # Laser Bag2 (/laserPrint) auto-send: same as listing + TCP send using DB LASER_PRINT.host / port / itemCodes. | |||||
| # Scheduler is off by default. limit-per-run: max job orders per tick (1 = first matching only); 0 = all matches (heavy). | |||||
| laser: | |||||
| bag2: | |||||
| auto-send: | |||||
| enabled: false | |||||
| interval-ms: 60000 | |||||
| limit-per-run: 1 | |||||
| bom: | bom: | ||||
| import: | import: | ||||
| temp-dir: ${java.io.tmpdir}/fpsms-bom-import | temp-dir: ${java.io.tmpdir}/fpsms-bom-import | ||||
| @@ -0,0 +1,22 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fai:20260326_py_job_order_print_submit | |||||
| CREATE TABLE IF NOT EXISTS `py_job_order_print_submit` ( | |||||
| `id` BIGINT NOT NULL AUTO_INCREMENT, | |||||
| `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| `createdBy` VARCHAR(30) NULL DEFAULT NULL, | |||||
| `version` INT NOT NULL DEFAULT '0', | |||||
| `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | |||||
| `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, | |||||
| `deleted` TINYINT(1) NOT NULL DEFAULT '0', | |||||
| `job_order_id` INT NOT NULL COMMENT 'FK job_order (must match job_order.id type)', | |||||
| `qty` INT NOT NULL COMMENT 'Quantity printed this submit (labels, bags, etc.)', | |||||
| `print_channel` VARCHAR(32) NOT NULL DEFAULT 'LABEL' COMMENT 'LABEL=標簽機, DATAFLEX=打袋機, …', | |||||
| CONSTRAINT `pk_py_job_order_print_submit` PRIMARY KEY (`id`), | |||||
| KEY `idx_py_jops_job_order` (`job_order_id`), | |||||
| KEY `idx_py_jops_channel_created` (`print_channel`, `created`), | |||||
| CONSTRAINT `fk_py_jops_job_order` FOREIGN KEY (`job_order_id`) REFERENCES `job_order` (`id`) | |||||
| ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci | |||||
| COMMENT='Per-submit print qty for Bag2/py clients; cumulative per job order for wastage/stock.'; | |||||
| @@ -0,0 +1,8 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fpsms:20260327_laser_print_last_receive_success | |||||
| INSERT INTO `settings` (`name`, `value`, `category`, `type`) | |||||
| SELECT 'LASER_PRINT.lastReceiveSuccess', '{}', 'LASER', 'string' | |||||
| WHERE NOT EXISTS ( | |||||
| SELECT 1 FROM `settings` s WHERE s.name = 'LASER_PRINT.lastReceiveSuccess' | |||||
| ); | |||||
| @@ -24,5 +24,4 @@ | |||||
| <export key="net.sf.jasperreports.xhtml">'華文宋體', Arial, Helvetica, sans-serif</export> | <export key="net.sf.jasperreports.xhtml">'華文宋體', Arial, Helvetica, sans-serif</export> | ||||
| </exportFonts> | </exportFonts> | ||||
| </fontFamily> | </fontFamily> | ||||
| </fontFamilies> | |||||
| </fontFamilies> | |||||