| @@ -36,3 +36,5 @@ out/ | |||
| ### VS Code ### | |||
| .vscode/ | |||
| 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 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 --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]. | |||
| * For these M18 user ids, FPSMS does not post GRN to M18 (see [com.ffii.fpsms.modules.stock.service.StockInLineService]). | |||
| * Remarks are shown on PO stock-in traceability report ([com.ffii.fpsms.modules.report.service.ReportService.searchStockInTraceabilityReport]). | |||
| */ | |||
| object M18GrnRules { | |||
| val SKIP_GRN_FOR_M18_CREATED_UIDS: Set<Long> = setOf(2569L, 2676L) | |||
| private val REMARKS_FOR_M18_CREATED_UID: Map<Long, String> = mapOf( | |||
| 2569L to "legato", | |||
| 2676L to "xtech", | |||
| ) | |||
| val SKIP_GRN_FOR_M18_CREATED_UIDS: Set<Long> = REMARKS_FOR_M18_CREATED_UID.keys | |||
| /** Display string for PDF/Excel: `2569 (legato)`, or plain id when unknown. */ | |||
| fun formatM18CreatedUidForReport(uid: Long?): String { | |||
| if (uid == null) return "" | |||
| val remark = REMARKS_FOR_M18_CREATED_UID[uid] | |||
| return if (remark != null) "$uid ($remark)" else uid.toString() | |||
| } | |||
| } | |||
| @@ -7,9 +7,12 @@ import java.time.LocalDateTime | |||
| 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 | |||
| /** 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, | |||
| * 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 { | |||
| logger.info("--------------------------------------------Start - Saving M18 Products / Materials--------------------------------------------") | |||
| ensureCunitSeededForAllIfEmpty() | |||
| @@ -74,6 +74,11 @@ class M18TestController ( | |||
| fun testSyncDoByCode(@RequestParam code: String): SyncResult { | |||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code) | |||
| } | |||
| @GetMapping("/test/product-by-code") | |||
| fun testSyncProductByCode(@RequestParam code: String): SyncResult { | |||
| return m18MasterDataService.saveProductByCode(code) | |||
| } | |||
| // --------------------------------------------- Scheduler --------------------------------------------- /// | |||
| // @GetMapping("/schedule/po") | |||
| // 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) */ | |||
| 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["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 ?: "" | |||
| 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 | |||
| @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.LaserBag2SendResponse | |||
| 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.LaserRequest | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest | |||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||
| import com.ffii.fpsms.py.PrintedQtyByChannel | |||
| import com.ffii.fpsms.py.PyJobOrderListItem | |||
| import com.ffii.fpsms.py.PyJobOrderPrintSubmitService | |||
| import org.springframework.core.env.Environment | |||
| import org.springframework.stereotype.Service | |||
| import java.awt.Color | |||
| import java.awt.Font | |||
| @@ -26,6 +31,10 @@ import javax.imageio.ImageIO | |||
| import com.google.zxing.BarcodeFormat | |||
| import com.google.zxing.EncodeHintType | |||
| import com.google.zxing.qrcode.QRCodeWriter | |||
| import java.net.URI | |||
| import java.net.http.HttpClient | |||
| import java.net.http.HttpRequest | |||
| import java.net.http.HttpResponse | |||
| import java.net.Socket | |||
| import java.net.InetSocketAddress | |||
| import java.io.PrintWriter | |||
| @@ -39,17 +48,32 @@ import java.net.ConnectException | |||
| import java.net.SocketTimeoutException | |||
| import org.springframework.core.io.ClassPathResource | |||
| import org.slf4j.LoggerFactory | |||
| import com.fasterxml.jackson.databind.ObjectMapper | |||
| import java.time.Duration | |||
| import java.time.Instant | |||
| import java.time.LocalDate | |||
| // Data class to store bitmap bytes + width (for XML) | |||
| 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 | |||
| class PlasticBagPrinterService( | |||
| val jobOrderRepository: JobOrderRepository, | |||
| private val jdbcDao: JdbcDao, | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| private val settingsService: SettingsService, | |||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | |||
| private val environment: Environment, | |||
| private val objectMapper: ObjectMapper, | |||
| ) { | |||
| private val logger = LoggerFactory.getLogger(javaClass) | |||
| @@ -75,7 +99,43 @@ class PlasticBagPrinterService( | |||
| val itemCodes = settingsService.findByName(SettingNames.LASER_PRINT_ITEM_CODES) | |||
| .map { it.value?.trim() ?: "" } | |||
| .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) | |||
| } | |||
| } | |||
| 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> { | |||
| @@ -119,13 +183,14 @@ class PlasticBagPrinterService( | |||
| .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 itemName = jo.bom?.name ?: jo.bom?.item?.name | |||
| val itemId = jo.bom?.item?.id | |||
| val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | |||
| val stockInLineId = stockInLine?.id | |||
| val lotNo = stockInLine?.lotNo | |||
| val p = printed ?: PrintedQtyByChannel() | |||
| return PyJobOrderListItem( | |||
| id = jo.id!!, | |||
| code = jo.code, | |||
| @@ -136,6 +201,9 @@ class PlasticBagPrinterService( | |||
| stockInLineId = stockInLineId, | |||
| itemId = itemId, | |||
| lotNo = lotNo, | |||
| bagPrintedQty = p.bagPrintedQty, | |||
| labelPrintedQty = p.labelPrintedQty, | |||
| laserPrintedQty = p.laserPrintedQty, | |||
| ) | |||
| } | |||
| @@ -154,22 +222,39 @@ class PlasticBagPrinterService( | |||
| itemCode = request.itemCode, | |||
| 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 { | |||
| @@ -190,6 +275,17 @@ class PlasticBagPrinterService( | |||
| 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( | |||
| ip: String, | |||
| port: Int, | |||
| @@ -197,7 +293,7 @@ class PlasticBagPrinterService( | |||
| stockInLineId: Long?, | |||
| itemCode: String?, | |||
| itemName: String?, | |||
| ): Triple<Boolean, String, String> { | |||
| ): LaserBag2TcpResult { | |||
| val codeStr = (itemCode ?: "").trim().replace(";", ",") | |||
| val nameStr = (itemName ?: "").trim().replace(";", ",") | |||
| val payload = if (itemId != null && stockInLineId != null) { | |||
| @@ -214,27 +310,61 @@ class PlasticBagPrinterService( | |||
| val out = socket.getOutputStream() | |||
| out.write(bytes) | |||
| 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 | |||
| try { | |||
| val buf = ByteArray(4096) | |||
| val n = socket.getInputStream().read(buf) | |||
| 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) { | |||
| // 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) { | |||
| return Triple(false, "無法連線至 $ip:$port,請確認激光機已開機且 IP 正確。", payload) | |||
| return LaserBag2TcpResult(false, "無法連線至 $ip:$port,請確認激光機已開機且 IP 正確。", payload, null, false) | |||
| } catch (e: SocketTimeoutException) { | |||
| return Triple(false, "連線逾時 ($ip:$port),請檢查網路與連接埠。", payload) | |||
| return LaserBag2TcpResult(false, "連線逾時 ($ip:$port),請檢查網路與連接埠。", payload, null, false) | |||
| } catch (e: Exception) { | |||
| return Triple(false, "激光機送出失敗:${e.message}", payload) | |||
| return LaserBag2TcpResult(false, "激光機送出失敗:${e.message}", payload, null, false) | |||
| } finally { | |||
| try { | |||
| Thread.sleep(100) | |||
| } catch (_: InterruptedException) { | |||
| Thread.currentThread().interrupt() | |||
| } | |||
| try { | |||
| socket?.close() | |||
| } catch (_: Exception) { | |||
| @@ -526,6 +656,59 @@ class PlasticBagPrinterService( | |||
| return baos.toByteArray() | |||
| } | |||
| /** | |||
| * Builds the same ZIP as [generateOnPackQrTextZip] and POSTs it to [ngpcl.push-url] (application/zip). | |||
| * When the URL is blank, returns [NgpclPushResponse] with pushed=false so callers can fall back to manual download. | |||
| */ | |||
| fun pushOnPackQrTextZipToNgpcl(jobOrders: List<OnPackQrJobOrderRequest>): NgpclPushResponse { | |||
| val url = (environment.getProperty("ngpcl.push-url") ?: "").trim() | |||
| if (url.isEmpty()) { | |||
| return NgpclPushResponse( | |||
| pushed = false, | |||
| message = "NGPCL push URL not configured. Set ngpcl.push-url or NGPCL_PUSH_URL, or download the ZIP and transfer loose files manually.", | |||
| ) | |||
| } | |||
| val zipBytes = try { | |||
| generateOnPackQrTextZip(jobOrders) | |||
| } catch (e: Exception) { | |||
| logger.warn("OnPack text ZIP generation failed before NGPCL push", e) | |||
| return NgpclPushResponse( | |||
| pushed = false, | |||
| message = e.message ?: "ZIP generation failed", | |||
| ) | |||
| } | |||
| return try { | |||
| val client = HttpClient.newBuilder() | |||
| .connectTimeout(Duration.ofSeconds(30)) | |||
| .build() | |||
| val request = HttpRequest.newBuilder() | |||
| .uri(URI.create(url)) | |||
| .timeout(Duration.ofMinutes(2)) | |||
| .header("Content-Type", "application/zip") | |||
| .POST(HttpRequest.BodyPublishers.ofByteArray(zipBytes)) | |||
| .build() | |||
| val response = client.send(request, HttpResponse.BodyHandlers.ofString()) | |||
| val body = response.body() | |||
| if (response.statusCode() in 200..299) { | |||
| NgpclPushResponse( | |||
| pushed = true, | |||
| message = "NGPCL accepted (HTTP ${response.statusCode()})${if (body.isNotBlank()) ": ${body.take(300)}" else ""}", | |||
| ) | |||
| } else { | |||
| NgpclPushResponse( | |||
| pushed = false, | |||
| message = "NGPCL returned HTTP ${response.statusCode()}: ${body.take(500)}", | |||
| ) | |||
| } | |||
| } catch (e: Exception) { | |||
| logger.error("NGPCL push failed", e) | |||
| NgpclPushResponse( | |||
| pushed = false, | |||
| message = e.message ?: "Push failed: ${e.javaClass.simpleName}", | |||
| ) | |||
| } | |||
| } | |||
| /** | |||
| * Returns uppercase item codes present in `onpack_qr` with the given [templateType] (`bmp` or `text`). | |||
| * Empty or NULL `template_type` is treated as `bmp` for backward compatibility. | |||
| @@ -759,7 +942,17 @@ class PlasticBagPrinterService( | |||
| ): Pair<Boolean, String> { | |||
| return when (printerType.lowercase()) { | |||
| "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" -> { | |||
| val comPort = labelCom?.trim().orEmpty() | |||
| if (comPort.isBlank()) { | |||
| @@ -781,14 +974,14 @@ class PlasticBagPrinterService( | |||
| return try { | |||
| Socket().use { socket -> | |||
| socket.connect(InetSocketAddress(ip, port), 3000) | |||
| true to "$printerName connected" | |||
| true to "$printerName 已連線($ip:$port)" | |||
| } | |||
| } catch (e: SocketTimeoutException) { | |||
| false to "$printerName connection timed out" | |||
| false to "$printerName 連線逾時($ip:$port)" | |||
| } catch (e: ConnectException) { | |||
| false to "$printerName connection refused" | |||
| false to "$printerName 無法連線($ip:$port)" | |||
| } 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 | |||
| import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService | |||
| 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.LaserRequest | |||
| 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.LaserBag2SendResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SettingsResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrDownloadRequest | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusRequest | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusResponse | |||
| @@ -24,6 +27,7 @@ import org.slf4j.LoggerFactory | |||
| @RequestMapping("/plastic") | |||
| class PlasticBagPrinterController( | |||
| private val plasticBagPrinterService: PlasticBagPrinterService, | |||
| private val laserBag2AutoSendService: LaserBag2AutoSendService, | |||
| ) { | |||
| 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") | |||
| fun checkPrinter(@RequestBody request: PrinterStatusRequest): ResponseEntity<PrinterStatusResponse> { | |||
| 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. | |||
| * 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). | |||
| * 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( | |||
| val itemId: Long? = null, | |||
| @@ -11,4 +14,9 @@ data class LaserBag2SendRequest( | |||
| val itemName: String? = null, | |||
| val printerIp: String? = 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 message: String, | |||
| 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 lastReceiveSuccess Last job where the laser printer returned a receive ack (from settings JSON); null if never recorded or empty. | |||
| */ | |||
| data class LaserBag2SettingsResponse( | |||
| val host: String, | |||
| val port: Int, | |||
| 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( | |||
| val jobOrderId: Long, | |||
| val itemCode: String, | |||
| ) | |||
| /** Result of POST /plastic/ngpcl/push-onpack-qr-text — server POSTs the lemon ZIP bytes to [ngpcl.push-url] when configured. */ | |||
| data class NgpclPushResponse( | |||
| val pushed: Boolean, | |||
| val message: String, | |||
| ) | |||
| @@ -647,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. | |||
| * Supports comma-separated values for stockCategory (items.type) and itemCode. | |||
| */ | |||
| @@ -737,7 +737,7 @@ return result | |||
| $lastInDateEndSql | |||
| ORDER BY it.code, sil.lotNo | |||
| """.trimIndent() | |||
| 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(COALESCE(cur.code, '')) AS currencyCode, | |||
| 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 | |||
| LEFT JOIN items it ON sil.itemId = it.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), | |||
| "currencyCode" to row["currencyCode"], | |||
| "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> | |||
| /** 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(""" | |||
| SELECT sil FROM StockInLine sil | |||
| 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. | |||
| * 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 | |||
| open class SearchCompletedDnService( | |||
| @@ -82,7 +83,7 @@ open class SearchCompletedDnService( | |||
| /** | |||
| * 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 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. | |||
| @@ -101,7 +102,7 @@ open class SearchCompletedDnService( | |||
| toProcess.forEach { (poId, silList) -> | |||
| silList.firstOrNull()?.let { first -> | |||
| try { | |||
| stockInLineService.processPurchaseOrderForGrn(first) | |||
| stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate) | |||
| } catch (e: Exception) { | |||
| 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 | |||
| open fun postCompletedDnAndProcessGrnWithMissingRetry( | |||
| @@ -172,9 +169,12 @@ open class SearchCompletedDnService( | |||
| val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L } | |||
| 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 | |||
| @@ -190,7 +190,7 @@ open class SearchCompletedDnService( | |||
| toProcess.forEach { (poId, silList) -> | |||
| silList.firstOrNull()?.let { first -> | |||
| try { | |||
| stockInLineService.processPurchaseOrderForGrn(first) | |||
| stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate) | |||
| } catch (e: Exception) { | |||
| 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. | |||
| * 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 | |||
| * RECEIVED and PENDING/ESCALATED status flows. | |||
| */ | |||
| private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted(savedStockInLine: StockInLine) { | |||
| private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted( | |||
| savedStockInLine: StockInLine, | |||
| grnReceiptDate: LocalDate? = null, | |||
| ) { | |||
| if (savedStockInLine.purchaseOrderLine == null) return | |||
| val pol = savedStockInLine.purchaseOrderLine ?: return | |||
| 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). | |||
| 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()) { | |||
| logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") | |||
| 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) { | |||
| 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 | |||
| @@ -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.http.ResponseEntity | |||
| 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.RequestParam | |||
| 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.LocalDateTime | |||
| @@ -23,6 +27,7 @@ open class PyController( | |||
| private val jobOrderRepository: JobOrderRepository, | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| private val plasticBagPrinterService: PlasticBagPrinterService, | |||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | |||
| ) { | |||
| companion object { | |||
| private const val PACKAGING_PROCESS_NAME = "包裝" | |||
| @@ -46,10 +51,34 @@ open class PyController( | |||
| dayEndExclusive, | |||
| 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) | |||
| } | |||
| /** | |||
| * 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] | |||
| * (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)) | |||
| } | |||
| private fun toListItem(jo: JobOrder): PyJobOrderListItem { | |||
| private fun toListItem(jo: JobOrder, printed: PrintedQtyByChannel?): PyJobOrderListItem { | |||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | |||
| val itemName = jo.bom?.name ?: jo.bom?.item?.name | |||
| val itemId = jo.bom?.item?.id | |||
| val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | |||
| val stockInLineId = stockInLine?.id | |||
| val lotNo = stockInLine?.lotNo | |||
| val p = printed ?: PrintedQtyByChannel() | |||
| return PyJobOrderListItem( | |||
| id = jo.id!!, | |||
| code = jo.code, | |||
| @@ -79,6 +109,9 @@ open class PyController( | |||
| stockInLineId = stockInLineId, | |||
| itemId = itemId, | |||
| lotNo = lotNo, | |||
| bagPrintedQty = p.bagPrintedQty, | |||
| labelPrintedQty = p.labelPrintedQty, | |||
| laserPrintedQty = p.laserPrintedQty, | |||
| ) | |||
| } | |||
| } | |||
| @@ -19,4 +19,10 @@ data class PyJobOrderListItem( | |||
| val stockInLineId: Long?, | |||
| val itemId: Long?, | |||
| 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"?> | |||
| <!-- 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"> | |||
| <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> | |||
| </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> | |||
| </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> | |||
| @@ -50,6 +50,20 @@ jwt: | |||
| logging: | |||
| config: 'classpath:log4j2.yml' | |||
| # Optional NGPCL gateway: receives the same bytes as /plastic/download-onpack-qr-text (Content-Type: application/zip). | |||
| # Leave empty to disable; set NGPCL_PUSH_URL in production if you expose an HTTP receiver for the lemon OnPack ZIP. | |||
| ngpcl: | |||
| push-url: ${NGPCL_PUSH_URL:} | |||
| # 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: | |||
| 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> | |||
| </exportFonts> | |||
| </fontFamily> | |||
| </fontFamilies> | |||
| </fontFamilies> | |||