| @@ -54,6 +54,7 @@ open class M18PurchaseOrderService( | |||
| // M18 API | |||
| val M18_LOAD_PURCHASE_ORDER_API = "/root/api/read/po" | |||
| val M18_LOAD_PURCHASE_ORDER_BY_CODE_API = "/root/api/read/po" | |||
| val M18_FETCH_PURCHASE_ORDER_LIST_API = "/search/search" | |||
| // Include material po, oem po | |||
| @@ -204,16 +205,82 @@ open class M18PurchaseOrderService( | |||
| return purchaseOrder | |||
| } | |||
| open fun getPurchaseOrderByCode(code: String): M18PurchaseOrderResponse? { | |||
| var purchaseOrder: M18PurchaseOrderResponse? = null | |||
| try { | |||
| purchaseOrder = apiCallerService.get<M18PurchaseOrderResponse>( | |||
| M18_LOAD_PURCHASE_ORDER_BY_CODE_API, | |||
| mapOf("code" to code) | |||
| ).block() | |||
| } catch (e: Exception) { | |||
| logger.error("(Getting Po Detail By Code) Error on Function - ${e.stackTrace}") | |||
| logger.error(e.message) | |||
| } | |||
| return purchaseOrder | |||
| } | |||
| private fun resolvePoTypeByBeId(beId: Long?): PurchaseOrderType { | |||
| return if (beId?.toString() == m18Config.BEID_TOA) PurchaseOrderType.SHOP else PurchaseOrderType.MATERIAL | |||
| } | |||
| open fun savePurchaseOrderByCode(code: String): SyncResult { | |||
| // Follow the scheduler's original approach: | |||
| // 1) use /search/search to find the PO id by code | |||
| // 2) then sync by PO id (this reuses the same create/update logic) | |||
| val searchRequest = M18PurchaseOrderListRequest( | |||
| stSearch = "po", | |||
| params = null, | |||
| conds = "(code=equal=$code)" | |||
| ) | |||
| val poListResponse = try { | |||
| apiCallerService.get<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>( | |||
| M18_FETCH_PURCHASE_ORDER_LIST_API, | |||
| searchRequest | |||
| ).block() | |||
| } catch (e: Exception) { | |||
| logger.error("(Getting Po List By Code) Error on Function - ${e.stackTrace}") | |||
| logger.error(e.message) | |||
| null | |||
| } | |||
| val poValues = poListResponse?.values | |||
| if (poValues.isNullOrEmpty()) { | |||
| return SyncResult( | |||
| totalProcessed = 1, | |||
| totalSuccess = 0, | |||
| totalFail = 1, | |||
| query = "code=equal=$code" | |||
| ) | |||
| } | |||
| val request = M18PurchaseOrderListResponseWithType( | |||
| valuesWithType = mutableListOf(Pair(PurchaseOrderType.MATERIAL, poListResponse)), | |||
| query = "code=equal=$code" | |||
| ) | |||
| return savePurchaseOrdersWithPreparedList(purchaseOrdersWithType = request) | |||
| } | |||
| open fun savePurchaseOrders(request: M18CommonRequest) : SyncResult{ | |||
| logger.info("--------------------------------------------Start - Saving M18 Purchase Order--------------------------------------------") | |||
| val purchaseOrdersWithType = getPurchaseOrdersWithType(request) | |||
| val examplePurchaseOrders = listOf<Long>(4764034L) | |||
| return savePurchaseOrdersWithPreparedList( | |||
| purchaseOrdersWithType = purchaseOrdersWithType, | |||
| preloadPurchaseOrderDetails = null | |||
| ) | |||
| } | |||
| private fun savePurchaseOrdersWithPreparedList( | |||
| purchaseOrdersWithType: M18PurchaseOrderListResponseWithType?, | |||
| preloadPurchaseOrderDetails: Map<Long, M18PurchaseOrderResponse?>? = null | |||
| ): SyncResult { | |||
| logger.info("--------------------------------------------Start - Saving M18 Purchase Order--------------------------------------------") | |||
| val successList = mutableListOf<Long>() | |||
| val successDetailList = mutableListOf<Long>() | |||
| val failList = mutableListOf<Long>() | |||
| val failDetailList = mutableListOf<Long>() | |||
| val affectedItemIds = mutableSetOf<Long>() | |||
| val uomByM18IdCache = mutableMapOf<Long, com.ffii.fpsms.modules.master.entity.ItemUom?>() | |||
| val poRefType = "Purchase Order" | |||
| val poLineRefType = "Purchase Order Line" | |||
| @@ -221,7 +288,6 @@ open class M18PurchaseOrderService( | |||
| if (purchaseOrdersWithType != null) { | |||
| // Loop for Purchase Orders (values) | |||
| purchaseOrdersWithType.valuesWithType.forEach { purchaseOrderWithType -> | |||
| val type = purchaseOrderWithType.first | |||
| // if success | |||
| val purchaseOrdersValues = purchaseOrderWithType.second?.values | |||
| // if fail | |||
| @@ -229,7 +295,8 @@ open class M18PurchaseOrderService( | |||
| if (purchaseOrdersValues != null) { | |||
| purchaseOrdersValues.forEach { purchaseOrder -> | |||
| val purchaseOrderDetail = getPurchaseOrder(purchaseOrder.id) | |||
| val purchaseOrderDetail = | |||
| preloadPurchaseOrderDetails?.get(purchaseOrder.id) ?: getPurchaseOrder(purchaseOrder.id) | |||
| var purchaseOrderId: Long? = null //FP-MTMS | |||
| @@ -242,10 +309,11 @@ open class M18PurchaseOrderService( | |||
| // purchase_order + m18_data_log table | |||
| if (mainpo != null) { | |||
| val m18PurchaseOrderId = mainpo.id | |||
| // Find the latest m18 data log by m18 id & type | |||
| // logger.info("${poRefType}: Finding For Latest M18 Data Log...") | |||
| val latestPurchaseOrderLog = | |||
| m18DataLogService.findLatestM18DataLogWithSuccess(purchaseOrder.id, poRefType) | |||
| m18DataLogService.findLatestM18DataLogWithSuccess(m18PurchaseOrderId, poRefType) | |||
| // logger.info(latestPurchaseOrderLog.toString()) | |||
| // Save to m18_data_log table | |||
| @@ -257,7 +325,7 @@ open class M18PurchaseOrderService( | |||
| val saveM18PurchaseOrderLogRequest = SaveM18DataLogRequest( | |||
| id = null, | |||
| refType = poRefType, | |||
| m18Id = purchaseOrder.id, | |||
| m18Id = m18PurchaseOrderId, | |||
| m18LastModifyDate = commonUtils.timestampToLocalDateTime(mainpo.lastModifyDate), | |||
| // dataLog = mainpoJson, | |||
| statusEnum = M18DataLogStatus.NOT_PROCESS | |||
| @@ -286,7 +354,7 @@ open class M18PurchaseOrderService( | |||
| estimatedArrivalDate = commonUtils.timestampToLocalDateTime(mainpo.dDate), | |||
| completeDate = null, | |||
| status = PurchaseOrderStatus.PENDING.value, | |||
| type = type.value, | |||
| type = resolvePoTypeByBeId(mainpo.beId).value, | |||
| m18DataLogId = saveM18PurchaseOrderLog.id, | |||
| m18BeId = mainpo.beId | |||
| ) | |||
| @@ -305,13 +373,13 @@ open class M18PurchaseOrderService( | |||
| m18DataLogService.saveM18DataLog(successSaveM18PurchaseOrderLogRequest) | |||
| // log success info | |||
| successList.add(purchaseOrder.id) | |||
| logger.info("${poRefType}: Saved purchase order. ID: ${savePurchaseOrderResponse.id} | M18 ${poRefType} ID: ${purchaseOrder.id}") | |||
| successList.add(m18PurchaseOrderId) | |||
| logger.info("${poRefType}: Saved purchase order. ID: ${savePurchaseOrderResponse.id} | M18 ${poRefType} ID: ${m18PurchaseOrderId}") | |||
| } catch (e: Exception) { | |||
| failList.add(purchaseOrder.id) | |||
| failList.add(m18PurchaseOrderId) | |||
| // logger.error("${poRefType}: Saving Failure!") | |||
| logger.error("Error on Function - ${e.stackTrace} | Type: ${poRefType} | M18 ID: ${purchaseOrder.id} | Different? ${mainpo.id}") | |||
| logger.error("Error on Function - ${e.stackTrace} | Type: ${poRefType} | M18 ID: ${m18PurchaseOrderId} | Different? ${mainpo.id}") | |||
| logger.error(e.message) | |||
| val errorSaveM18PurchaseOrderLogRequest = SaveM18DataLogRequest( | |||
| @@ -359,11 +427,10 @@ open class M18PurchaseOrderService( | |||
| // logger.info("${poLineRefType}: Saved M18 Data Log. ID: ${saveM18PurchaseOrderLineLog.id}") | |||
| // logger.info("${poLineRefType}: Finding item...") | |||
| val item = itemsService.findByM18Id(line.proId) | |||
| var itemId: Long? = null | |||
| if (item == null) { | |||
| itemId = m18MasterDataService.saveProduct(line.proId)?.id | |||
| val itemId: Long? = if (item == null) { | |||
| m18MasterDataService.saveProduct(line.proId)?.id | |||
| } else { | |||
| itemId = item.id | |||
| item.id | |||
| } | |||
| logger.info("${poLineRefType}: Item ID: ${itemId} | M18 Item ID: ${line.proId}") | |||
| @@ -377,13 +444,26 @@ open class M18PurchaseOrderService( | |||
| // Save to purchase_order_line table | |||
| // logger.info("${poLineRefType}: Saving purchase order line...") | |||
| val itemUom = itemId?.let { itemUomService.findPurchaseUnitByItemId(it) } | |||
| val purchaseItemUom = itemId?.let { itemUomService.findPurchaseUnitByItemId(it) } | |||
| val m18ItemUom = uomByM18IdCache.getOrPut(line.unitId) { | |||
| itemUomService.findByM18Id(line.unitId) | |||
| } | |||
| val qtyM18 = line.qty | |||
| val sourceUomId = m18ItemUom?.uom?.id | |||
| // qtyM18 is the original qty in M18 line uom. | |||
| // System PO line.qty must be qty in purchase unit. | |||
| val convertedQty = if (itemId != null && sourceUomId != null) { | |||
| itemUomService.convertQtyToPurchaseQty(itemId, sourceUomId, qtyM18) | |||
| } else { | |||
| qtyM18 | |||
| } | |||
| val savePurchaseOrderLineRequest = SavePurchaseOrderLineRequest( | |||
| id = existingPurchaseOrderLine?.id, | |||
| itemId = itemId, | |||
| uomId = itemUom?.uom?.id, | |||
| uomId = purchaseItemUom?.uom?.id, | |||
| purchaseOrderId = purchaseOrderId, | |||
| qty = line.qty, | |||
| qty = convertedQty, | |||
| qtyM18 = qtyM18, | |||
| up = line.up, | |||
| price = line.amt, | |||
| // m18CurrencyId = mainpo.curId, | |||
| @@ -391,7 +471,8 @@ open class M18PurchaseOrderService( | |||
| ?: PurchaseOrderLineStatus.PENDING.value, | |||
| m18DataLogId = saveM18PurchaseOrderLineLog.id, | |||
| m18Discount = line.disc, | |||
| m18Lot = line.lot | |||
| m18Lot = line.lot, | |||
| uomIdM18 = sourceUomId | |||
| ) | |||
| val savePurchaseOrderLineResponse = | |||
| @@ -443,7 +524,7 @@ open class M18PurchaseOrderService( | |||
| val saveM18PurchaseOrderLineLogRequest = SaveM18DataLogRequest( | |||
| id = null, | |||
| refType = "${poLineRefType}", | |||
| m18Id = purchaseOrder.id, | |||
| m18Id = m18PurchaseOrderId, | |||
| m18LastModifyDate = commonUtils.timestampToLocalDateTime(mainpo.lastModifyDate), | |||
| // dataLog = mutableMapOf(Pair("Error Message", "${poLineRefType} is null")), | |||
| dataLog = mutableMapOf( | |||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.m18.web | |||
| import com.ffii.core.utils.JwtTokenUtil | |||
| import com.ffii.fpsms.m18.M18Config | |||
| import com.ffii.fpsms.m18.model.SyncResult | |||
| import com.ffii.fpsms.m18.service.* | |||
| import com.ffii.fpsms.m18.web.models.M18CommonRequest | |||
| import com.ffii.fpsms.modules.common.SettingNames | |||
| @@ -63,6 +64,11 @@ class M18TestController ( | |||
| fun test4(): Any { | |||
| return schedulerService.getM18Pos(); | |||
| } | |||
| @GetMapping("/test/po-by-code") | |||
| fun testSyncPoByCode(@RequestParam code: String): SyncResult { | |||
| return m18PurchaseOrderService.savePurchaseOrderByCode(code) | |||
| } | |||
| // --------------------------------------------- Scheduler --------------------------------------------- /// | |||
| // @GetMapping("/schedule/po") | |||
| // fun schedulePo(@RequestParam @Valid newCron: String) { | |||
| @@ -581,54 +581,72 @@ open class PlasticBagPrinterService( | |||
| } | |||
| fun sendDataFlex6330Zpl(request: PrintRequest) { | |||
| Socket().use { socket -> | |||
| try { | |||
| // Connect with timeout | |||
| socket.connect(InetSocketAddress(request.printerIp, request.printerPort), 5000) | |||
| socket.soTimeout = 5000 // read timeout if expecting response (optional) | |||
| val out = socket.getOutputStream() | |||
| fun zplEscape(s: String): String { | |||
| // Match python Bag2.py: escape "\" as "\\" and "^" as "\^" | |||
| return s.replace("\\", "\\\\").replace("^", "\\^") | |||
| } | |||
| // Build ZPL dynamically | |||
| val zpl = buildString { | |||
| append("^XA\n") | |||
| append("^PW500\n") // Print width ~42mm @300dpi; adjust 400-630 based on head | |||
| append("^LL280\n") // Label height ~23mm; tune for your pouch | |||
| append("^PON\n") // Normal orientation | |||
| append("^CI28\n") // UTF-8 / extended char set for Chinese | |||
| fun buildZplDataFlex(): String { | |||
| val desc = zplEscape((request.itemName.takeIf { it.isNotBlank() } ?: "—").trim()) | |||
| val code = zplEscape((request.itemCode.takeIf { it.isNotBlank() } ?: "—").trim()) | |||
| // Chinese product name / description (top) | |||
| append("^FO20,20^A@N,36,36,E:SIMSUN.FNT^FD${request.itemName}^FS\n") // Assumes font loaded | |||
| val lot = request.lotNo.trim() | |||
| val batchNo = if (lot.isBlank()) "—" else lot | |||
| // Item code | |||
| append("^FO20,80^A0N,32,32^FD${request.itemCode}^FS\n") | |||
| val labelLine = (if (lot.isBlank()) null else lot) ?: batchNo | |||
| val labelEsc = zplEscape(labelLine) | |||
| // Expiry date | |||
| append("^FO20,120^A0N,28,28^FDEXP: ${request.expiryDate}^FS\n") | |||
| val qrPayload = if (request.itemId != null && request.stockInLineId != null) { | |||
| // Keep same spacing style as python json.dumps (default). | |||
| "{\"itemId\": ${request.itemId}, \"stockInLineId\": ${request.stockInLineId}}" | |||
| } else { | |||
| if (labelLine.isNotBlank()) labelLine else batchNo | |||
| } | |||
| val qrValue = zplEscape(qrPayload) | |||
| // Must match python Bag2.py generate_zpl_dataflex() | |||
| val fontRegular = "E:STXihei.ttf" | |||
| val fontBold = "E:STXihei.ttf" | |||
| return """ | |||
| ^XA | |||
| ^CI28 | |||
| ^PW700 | |||
| ^LL500 | |||
| ^PO N | |||
| ^FO10,20 | |||
| ^BQN,2,4^FDQA,$qrValue^FS | |||
| ^FO170,20 | |||
| ^A@R,72,72,$fontRegular^FD$desc^FS | |||
| ^FO0,200 | |||
| ^A@R,72,72,$fontRegular^FD$labelEsc^FS | |||
| ^FO55,200 | |||
| ^A@R,88,88,$fontBold^FD$code^FS | |||
| ^XZ | |||
| """.trimIndent() | |||
| } | |||
| // Lot / Batch No. | |||
| append("^FO20,160^A0N,28,28^FDLOT: ${request.lotNo}^FS\n") | |||
| val rawQty = request.printQty | |||
| val qty = if (rawQty == -1) 100 else rawQty.coerceAtLeast(1) | |||
| // QR code encoding lotNo (or combine: item|lot|exp) | |||
| // Position right side, mag 6 (~good size), model 2 | |||
| val qrData = request.lotNo // or "${request.itemCode}|${request.lotNo}|${request.expiryDate}" | |||
| append("^FO320,20^BQN,2,6^FDQA,$qrData^FS\n") // QA, prefix for alphanumeric mode | |||
| val zpl = buildZplDataFlex() | |||
| append("^XZ\n") | |||
| repeat(qty) { idx -> | |||
| Socket().use { socket -> | |||
| try { | |||
| socket.connect(InetSocketAddress(request.printerIp, request.printerPort), 5000) | |||
| val out = socket.getOutputStream() | |||
| out.write(zpl.toByteArray(Charsets.UTF_8)) | |||
| out.flush() | |||
| } catch (e: Exception) { | |||
| throw RuntimeException( | |||
| "DataFlex ZPL communication failed (sent ${idx} of ${qty}) to ${request.printerIp}:${request.printerPort}: ${e.message}", | |||
| e | |||
| ) | |||
| } | |||
| // Send as bytes (UTF-8 safe) | |||
| out.write(zpl.toByteArray(Charsets.UTF_8)) | |||
| out.flush() | |||
| println("DataFlex 6330 ZPL: Print job sent to ${request.printerIp}:${request.printerPort}") | |||
| // Optional: read response if printer echoes anything (rare in raw ZPL) | |||
| // val response = socket.getInputStream().readBytes().decodeToString() | |||
| // println("Response: $response") | |||
| } catch (e: Exception) { | |||
| throw RuntimeException("DataFlex 6330 ZPL communication failed: ${e.message}", e) | |||
| } | |||
| } | |||
| println("DataFlex ZPL: sent ${qty} labels to ${request.printerIp}:${request.printerPort}") | |||
| } | |||
| } | |||
| @@ -1,12 +1,17 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.web.model | |||
| data class PrintRequest( | |||
| val itemCode: String, | |||
| val itemName: String, | |||
| val lotNo: String, | |||
| val expiryDate: String, | |||
| val itemCode: String = "—", | |||
| val itemName: String = "—", | |||
| val lotNo: String = "—", | |||
| val expiryDate: String? = null, | |||
| val printerIp: String, | |||
| val printerPort: Int | |||
| val printerPort: Int, | |||
| // Used for DataFlex QR payload (same semantics as python Bag2.py) | |||
| val itemId: Long? = null, | |||
| val stockInLineId: Long? = null, | |||
| // For UI "+50/+10/+5/+1/C" printing. When -1 means continuous mode (backend prints up to 100). | |||
| val printQty: Int = 1 | |||
| ) | |||
| data class LaserRequest( | |||
| @@ -138,6 +138,27 @@ open class ItemUomService( | |||
| return purchaseQty.setScale(0, RoundingMode.UP) | |||
| } | |||
| /** | |||
| * Convert source quantity from a specific UOM to this item's purchase unit quantity. | |||
| * Returns source qty when source/purchase unit mapping is not found. | |||
| */ | |||
| open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | |||
| val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | |||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return sourceQty | |||
| val one = BigDecimal.ONE | |||
| val calcScale = 10 | |||
| val baseQty = sourceQty | |||
| .multiply(sourceItemUom.ratioN ?: one) | |||
| .divide(sourceItemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP) | |||
| val purchaseQty = baseQty | |||
| .multiply(purchaseUnit.ratioD ?: one) | |||
| .divide(purchaseUnit.ratioN ?: one, calcScale, RoundingMode.HALF_UP) | |||
| return purchaseQty.setScale(0, RoundingMode.UP) | |||
| } | |||
| open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | |||
| val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty; | |||
| @@ -36,6 +36,9 @@ open class PurchaseOrderLine : BaseEntity<Long>() { | |||
| @Column(name = "qty", precision = 14, scale = 2) | |||
| open var qty: BigDecimal? = null | |||
| @Column(name = "qtyM18", precision = 14, scale = 2) | |||
| open var qtyM18: BigDecimal? = null | |||
| @Column(name = "up", precision = 14, scale = 2) | |||
| open var up: BigDecimal? = null | |||
| @@ -63,6 +66,10 @@ open class PurchaseOrderLine : BaseEntity<Long>() { | |||
| @Column(name = "m18Lot", length = 20) | |||
| open var m18Lot: String? = null | |||
| @ManyToOne | |||
| @JoinColumn(name = "uomIdM18") | |||
| open var uomM18: UomConversion? = null | |||
| @JsonManagedReference | |||
| @OneToMany(mappedBy = "purchaseOrderLine", cascade = [CascadeType.ALL], orphanRemoval = true) | |||
| open var stockInLines: MutableList<StockInLine> = mutableListOf() | |||
| @@ -71,15 +71,18 @@ open class PurchaseOrderLineService( | |||
| // val currency = request.m18CurrencyId?.let { currencyService.findByM18Id(it) } | |||
| // ?: request.currencyId?.let { currencyService.findById(it) } | |||
| val uom = request.uomId?.let { uomConversionService.find(it).getOrNull() } | |||
| val uomM18 = request.uomIdM18?.let { uomConversionService.find(it).getOrNull() } | |||
| purchaseOrderLine.apply { | |||
| this.item = item | |||
| itemNo = item?.code | |||
| this.purchaseOrder = purchaseOrder | |||
| qty = request.qty | |||
| qtyM18 = request.qtyM18 | |||
| up = request.up | |||
| price = request.price | |||
| this.uom = uom | |||
| this.uomM18 = uomM18 | |||
| // this.currency = currency | |||
| this.status = status | |||
| this.m18DataLog = m18DataLog ?: this.m18DataLog | |||
| @@ -8,6 +8,7 @@ data class SavePurchaseOrderLineRequest( | |||
| val uomId: Long?, | |||
| val purchaseOrderId: Long?, | |||
| val qty: BigDecimal?, | |||
| val qtyM18: BigDecimal?, | |||
| val up: BigDecimal?, // unit price | |||
| val price: BigDecimal?, | |||
| // val currencyId: Long? = null, | |||
| @@ -16,4 +17,5 @@ data class SavePurchaseOrderLineRequest( | |||
| val m18DataLogId: Long?, | |||
| val m18Discount: BigDecimal?, | |||
| val m18Lot: String?, | |||
| ) | |||
| val uomIdM18: Long?, | |||
| ) | |||
| @@ -0,0 +1,8 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset codex:add_uomidm18_to_po_line | |||
| -- preconditions onFail:MARK_RAN | |||
| -- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'purchase_order_line' AND COLUMN_NAME = 'uomIdM18' | |||
| ALTER TABLE `purchase_order_line` | |||
| ADD COLUMN `uomIdM18` INT NULL AFTER `uomId`, | |||
| ADD CONSTRAINT `FK_PURCHASE_ORDER_LINE_ON_UOMIDM18` FOREIGN KEY (`uomIdM18`) REFERENCES `uom_conversion` (`id`); | |||
| @@ -0,0 +1,7 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset codex:add_qtym18_to_po_line | |||
| -- preconditions onFail:MARK_RAN | |||
| -- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'purchase_order_line' AND COLUMN_NAME = 'qtyM18' | |||
| ALTER TABLE `purchase_order_line` | |||
| ADD COLUMN `qtyM18` DECIMAL(14,2) NULL AFTER `qty`; | |||