| @@ -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 | |||
| @@ -12,7 +12,9 @@ import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | |||
| 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.stereotype.Service | |||
| import java.awt.Color | |||
| import java.awt.Font | |||
| @@ -50,6 +52,7 @@ class PlasticBagPrinterService( | |||
| private val jdbcDao: JdbcDao, | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| private val settingsService: SettingsService, | |||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | |||
| ) { | |||
| private val logger = LoggerFactory.getLogger(javaClass) | |||
| @@ -108,7 +111,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 +126,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 +144,9 @@ class PlasticBagPrinterService( | |||
| stockInLineId = stockInLineId, | |||
| itemId = itemId, | |||
| lotNo = lotNo, | |||
| bagPrintedQty = p.bagPrintedQty, | |||
| labelPrintedQty = p.labelPrintedQty, | |||
| laserPrintedQty = p.laserPrintedQty, | |||
| ) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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.'; | |||