Bladeren bron

adding for bom sync

production
[email protected] 1 maand geleden
bovenliggende
commit
dd348f36ae
9 gewijzigde bestanden met toevoegingen van 278 en 38 verwijderingen
  1. +2
    -1
      .gitignore
  2. +9
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt
  3. +8
    -1
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt
  4. +11
    -1
      src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt
  5. +205
    -34
      src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt
  6. +3
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
  7. +32
    -0
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt
  8. +1
    -1
      src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql
  9. +7
    -0
      src/main/resources/db/changelog/changes/20260515_fpsms/01_m18_bom_shop_sync_log_columns.sql

+ 2
- 1
.gitignore Bestand weergeven

@@ -37,4 +37,5 @@ out/
.vscode/
package-lock.json
python/Bag3.spec
python/dist/Bag3.exe
python/dist


+ 9
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt Bestand weergeven

@@ -17,6 +17,15 @@ open class M18BomShopSyncLog : BaseEntity<Long>() {
@Column(name = "bom_id", nullable = false)
open var bomId: Long? = null

@Column(name = "finished_item_code", length = 100)
open var finishedItemCode: String? = null

@Column(name = "m18_header_code", length = 200)
open var m18HeaderCode: String? = null

@Column(name = "request_fingerprint", length = 64)
open var requestFingerprint: String? = null

@Column(name = "m18_record_id")
open var m18RecordId: Long? = null



+ 8
- 1
src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt Bestand weergeven

@@ -2,4 +2,11 @@ package com.ffii.fpsms.m18.entity

import com.ffii.core.support.AbstractRepository

interface M18BomShopSyncLogRepository : AbstractRepository<M18BomShopSyncLog, Long>
interface M18BomShopSyncLogRepository : AbstractRepository<M18BomShopSyncLog, Long> {
fun findFirstByBomIdOrderByIdDesc(bomId: Long): M18BomShopSyncLog?

fun findTop100ByBomIdOrderByIdDesc(bomId: Long): List<M18BomShopSyncLog>

/** Successful M18 udfBomForShop saves only — used for `BOM{item}Vnnn` version allocation. */
fun findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId: Long): List<M18BomShopSyncLog>
}

+ 11
- 1
src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt Bestand weergeven

@@ -53,6 +53,8 @@ data class M18MainUdfBomForShopValue(
@JsonProperty("udfYieldratePP")
val udfYieldratePP: Number? = null,
val udftypeoffood: String? = null,
@JsonProperty("udfconfirmed")
val udfconfirmed: Boolean? = null,
val staffId: Int? = null,
val flowTypeId: Int? = null,
val virDeptId: Int? = null,
@@ -66,7 +68,7 @@ data class M18UdfProductWrapper(

/**
* Line payload for `udfproduct.values[]`. **`udfBaseUnit`** is the FPSMS UOM **code** for the line.
* **`udfpurchaseUnit`** / **`udfPackingUnit`** / **`udfPackingQty`** / **`udfproremark`** are not sent.
* **`udfPackingUnit`** / **`udfPackingQty`** / **`udfproremark`** are not sent.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18UdfProductSaveValue(
@@ -77,7 +79,15 @@ data class M18UdfProductSaveValue(
val udfIngredients: String? = null,
/** Line UOM: [com.ffii.fpsms.modules.master.entity.UomConversion.code] (same unit as [udfqty]). */
val udfBaseUnit: String? = null,
/** PO supplier [com.ffii.fpsms.modules.master.entity.Shop.m18Id] (via `purchase_order.supplierId`) for latest PO line matching material [com.ffii.fpsms.modules.master.entity.Items.code]. */
val udfSupplier: Long? = null,
/**
* M18 UOM id for price/purchase unit on the **M18-linked** PO line (`m18DataLog` present):
* [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine.uomM18] (M18 `unitId`) then
* [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine.uom] → [com.ffii.fpsms.modules.master.entity.UomConversion.m18Id].
*/
@JsonProperty("udfpurchaseUnit")
val udfpurchaseUnit: Long? = null,
/** Line sequence, e.g. " 1" */
val itemNo: String? = null,
val udfoptions: String? = null,


+ 205
- 34
src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt Bestand weergeven

@@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.ffii.fpsms.api.service.ApiCallerService
import com.ffii.fpsms.m18.M18Config
import com.ffii.fpsms.m18.entity.M18BomShopSyncLog
import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository
import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse
import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest
import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue
@@ -14,14 +16,17 @@ import com.ffii.fpsms.m18.model.M18UdfProductWrapper
import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.entity.BomMaterial
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLineRepository
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.util.LinkedMultiValueMap
import reactor.core.publisher.Mono
import java.math.BigDecimal
import java.math.RoundingMode
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.ZoneId

/**
@@ -33,6 +38,8 @@ open class M18BomForShopService(
private val m18Config: M18Config,
private val apiCallerService: ApiCallerService,
private val itemUomService: ItemUomService,
private val purchaseOrderLineRepository: PurchaseOrderLineRepository,
private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository,
) {
private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java)

@@ -51,6 +58,9 @@ open class M18BomForShopService(
companion object {
private const val HARVEST_CALC_SCALE = 10
private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong")

private fun formatBomShopHeaderCode(itemCode: String, version: Int): String =
"BOM${itemCode}V${version.toString().padStart(3, '0')}"
}

@Suppress("DEPRECATION")
@@ -58,55 +68,104 @@ open class M18BomForShopService(
disable(JsonGenerator.Feature.ESCAPE_NON_ASCII)
}

/**
* Stable hash of payload **excluding** M18 header `id`, `code`, and `rev` (so version bumps do not affect equality).
* Used with [M18BomShopSyncLog] to decide V000 vs V001+.
*/
open fun contentFingerprint(request: M18BomForShopSaveRequest): String {
val json = objectMapper.writeValueAsString(normalizedForFingerprint(request))
return sha256Hex(json)
}

private fun normalizedForFingerprint(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest {
val v = request.udfbomforshop.values.firstOrNull()
?: return request
val headerNorm = v.copy(id = null, code = null, rev = null)
val linesSorted = request.udfproduct.values.sortedWith(
compareBy({ it.itemNo }, { it.udfProduct }, { it.udfIngredients }),
)
return M18BomForShopSaveRequest(
udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(headerNorm)),
udfproduct = M18UdfProductWrapper(values = linesSorted),
)
}

private fun sha256Hex(text: String): String {
val md = MessageDigest.getInstance("SHA-256")
val bytes = md.digest(text.toByteArray(StandardCharsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}

/**
* Builds M18 save body from a persisted BOM (materials loaded).
* [headerM18IdOverride] optional M18 header record id (e.g. from `/bom/by-item-code` `bomM18Id`) when DB column is stale.
* Otherwise uses [Bom.m18Id] when set for **update**; omitted for **create**.
* Returns null if required M18 ids are missing (caller should log and skip).
* [headerM18IdOverride] optional M18 header record id when forcing update; skips version/fingerprint logic for **id** only,
* reuses latest logged [M18BomShopSyncLog.m18HeaderCode] when possible.
* Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnn`.
*/
open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? {
val code = bom.code ?: return null
val flowTypeId = resolveFlowTypeId(code)
val bomId = bom.id ?: return null
val routingCode = bom.code ?: return null
val itemCode = bom.item?.code?.trim().orEmpty().ifEmpty {
logger.warn("[M18 BOM] bom.item.code missing; cannot build M18 BOM shop payload. bomId=$bomId")
return null
}

val flowTypeId = resolveFlowTypeId(routingCode)
val udfUnit = bom.uom?.m18Id?.takeIf { it > 0 } ?: return null
val outputQty = bom.outputQty ?: BigDecimal.ZERO
val (udfHarvest, udfHarvestUnit) = resolveUdfHarvestFields(bom, outputQty)
val udfEffectiveDate = bom.created?.atZone(m18Tz)?.toInstant()?.toEpochMilli()

val effectiveHeaderM18Id =
headerM18IdOverride?.takeIf { it > 0 } ?: bom.m18Id?.takeIf { it > 0 }
val lines = bom.bomMaterials
.filter { it.deleted != true }
.sortedBy { it.id ?: 0L }
.mapIndexedNotNull { idx, mat -> toProductLine(mat, idx + 1) }

if (lines.isEmpty()) {
logger.warn("[M18 BOM] BOM id=$bomId code=$routingCode has no materials; skipping M18 save")
return null
}

val (headerCode, rev, headerM18IdForRequest) = resolveHeaderCodeAndM18Id(
bomId = bomId,
itemCode = itemCode,
lines = lines,
udfUnit = udfUnit,
udfHarvest = udfHarvest,
udfHarvestUnit = udfHarvestUnit,
udfEffectiveDate = udfEffectiveDate,
bomYield = bom.yield,
bomName = bom.name,
bomDescription = bom.description,
flowTypeId = flowTypeId,
headerM18IdOverride = headerM18IdOverride,
bomM18Id = bom.m18Id?.takeIf { it > 0 },
)

val header = M18MainUdfBomForShopValue(
id = effectiveHeaderM18Id?.toString(),
code = code,
id = headerM18IdForRequest?.toString(),
code = headerCode,
beId = bomShopMainBeId,
desc = bom.name ?: bom.description,
descEn = bom.name ?: bom.description,
udfBomCode = deriveUdfBomCode(code),
rev = deriveRev(code),
udfBomCode = itemCode,
rev = rev,
udfUnit = udfUnit,
udfHarvest = udfHarvest,
udfHarvestUnit = udfHarvestUnit,
udfEffectiveDate = udfEffectiveDate,
udfYieldratePP = bom.yield,
udftypeoffood = "半成品",
udfconfirmed = true,
staffId = 232,
flowTypeId = flowTypeId,
virDeptId = 117,
status = "Y",
)

val lines = bom.bomMaterials
.filter { it.deleted != true }
.sortedBy { it.id ?: 0L }
.mapIndexedNotNull { idx, mat -> toProductLine(mat, idx + 1) }

if (lines.isEmpty()) {
logger.warn("[M18 BOM] BOM id=${bom.id} code=$code has no materials; skipping M18 save")
return null
}

logger.info(
"[M18 BOM] buildSaveRequest fpsmsBomId=${bom.id} code=$code mainM18Id=$effectiveHeaderM18Id " +
"(m18HeaderIdOverride=$headerM18IdOverride, bom.m18Id=${bom.m18Id})",
"[M18 BOM] buildSaveRequest fpsmsBomId=$bomId routingCode=$routingCode itemCode=$itemCode headerCode=$headerCode " +
"mainM18Id=$headerM18IdForRequest (override=$headerM18IdOverride, bom.m18Id=${bom.m18Id})",
)

return M18BomForShopSaveRequest(
@@ -115,6 +174,110 @@ open class M18BomForShopService(
)
}

@Suppress("LongParameterList")
private fun resolveHeaderCodeAndM18Id(
bomId: Long,
itemCode: String,
lines: List<M18UdfProductSaveValue>,
udfUnit: Long,
udfHarvest: String,
udfHarvestUnit: String?,
udfEffectiveDate: Long?,
bomYield: BigDecimal?,
bomName: String?,
bomDescription: String?,
flowTypeId: Int,
headerM18IdOverride: Long?,
bomM18Id: Long?,
): Triple<String, String?, Long?> {
val draftHeader = M18MainUdfBomForShopValue(
id = null,
code = null,
beId = bomShopMainBeId,
desc = bomName ?: bomDescription,
descEn = bomName ?: bomDescription,
udfBomCode = itemCode,
rev = null,
udfUnit = udfUnit,
udfHarvest = udfHarvest,
udfHarvestUnit = udfHarvestUnit,
udfEffectiveDate = udfEffectiveDate,
udfYieldratePP = bomYield,
udftypeoffood = "半成品",
udfconfirmed = true,
staffId = 232,
flowTypeId = flowTypeId,
virDeptId = 117,
status = "Y",
)
val draftRequest = M18BomForShopSaveRequest(
udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(draftHeader)),
udfproduct = M18UdfProductWrapper(values = lines),
)
val fp = contentFingerprint(draftRequest)

val forcedId = headerM18IdOverride?.takeIf { it > 0 }
if (forcedId != null) {
val latest = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId)
val codeForUpdate =
latest?.m18HeaderCode?.takeIf { it.isNotBlank() }
?: formatBomShopHeaderCode(itemCode, 0)
val forcedRev = parseTrailingVersion(codeForUpdate) ?: "000"
return Triple(codeForUpdate, forcedRev, forcedId)
}

val latestLog = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId)
val prevFp = resolveLogFingerprint(latestLog)
val prevCodeTrimmed = latestLog?.m18HeaderCode?.trim().orEmpty()
val samePayload = latestLog != null && prevFp != null && prevFp == fp && prevCodeTrimmed.isNotEmpty()

if (samePayload) {
val revReuse = parseTrailingVersion(prevCodeTrimmed) ?: "000"
return Triple(prevCodeTrimmed, revReuse, bomM18Id)
}

val maxV = maxVersionFromLogs(bomId, itemCode)
val nextV = maxV + 1
val newCode = formatBomShopHeaderCode(itemCode, nextV)
val rev = nextV.toString().padStart(3, '0')
return Triple(newCode, rev, null)
}

private fun resolveLogFingerprint(log: M18BomShopSyncLog?): String? {
if (log == null) return null
log.requestFingerprint?.takeIf { it.isNotBlank() }?.let { return it }
val json = log.requestJson ?: return null
return runCatching {
val prev = objectMapper.readValue(json, M18BomForShopSaveRequest::class.java)
contentFingerprint(prev)
}.getOrNull()
}

private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int {
val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$")
// Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnn.
return m18BomShopSyncLogRepository.findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId)
.mapNotNull { row ->
val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty {
extractHeaderCodeFromJson(row.requestJson).orEmpty()
}
versionPat.find(c)?.groupValues?.get(1)?.toIntOrNull()
}
.maxOrNull() ?: -1
}

private fun extractHeaderCodeFromJson(json: String?): String? {
if (json.isNullOrBlank()) return null
return runCatching {
val node = objectMapper.readTree(json)
val text = node.path("udfbomforshop").path("values").path(0).path("code").asText()
text.trim().takeIf { it.isNotEmpty() }
}.getOrNull()
}

private fun parseTrailingVersion(headerCode: String): String? =
Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.padStart(3, '0')

/**
* From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code]
* (pattern `LETTER_PREFIX` + `DIGITS` + `UNIT_SUFFIX`, e.g. PACK2LB): harvest qty = outputQty × digits, unit = suffix.
@@ -160,6 +323,23 @@ open class M18BomForShopService(
logger.warn("[M18 BOM] material UOM code missing bomMaterialId=${mat.id}")
return null
}
val itemId = mat.item?.id
val latestPoLine = itemId?.let { id ->
purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 1)).firstOrNull()
}
val itemCode = mat.item?.code?.trim()?.takeIf { it.isNotEmpty() }
val supplierM18Id = itemCode?.let { code ->
purchaseOrderLineRepository.findLatestPoSupplierM18IdByItemCodeNative(code)
.firstOrNull()
?.takeIf { it > 0L }
}
/**
* M18 line price unit id ([M18PurchaseOrderPot.unitId]): prefer [PurchaseOrderLine.uomM18] from M18 PO sync,
* else [PurchaseOrderLine.uom] when uomM18 is missing.
*/
val purchaseUnitM18Id =
latestPoLine?.uomM18?.m18Id?.takeIf { it > 0L }
?: latestPoLine?.uom?.m18Id?.takeIf { it > 0L }
val udfqty = (mat.qty ?: BigDecimal.ZERO).setScale(8, RoundingMode.HALF_UP).toDouble()
return M18UdfProductSaveValue(
id = mat.m18Id?.takeIf { it > 0 },
@@ -167,7 +347,8 @@ open class M18BomForShopService(
udfProduct = proId,
udfIngredients = mat.itemName ?: mat.item?.name,
udfBaseUnit = udfBaseUnit,
udfSupplier = 0L,
udfSupplier = supplierM18Id,
udfpurchaseUnit = purchaseUnitM18Id,
itemNo = String.format("%6d", lineNo),
udfoptions = "",
udfoption = 0.0,
@@ -175,16 +356,6 @@ open class M18BomForShopService(
)
}

private fun deriveUdfBomCode(fullCode: String): String {
val v = Regex("^(.*)V(\\d+)$").find(fullCode)
return if (v != null) v.groupValues[1] else fullCode
}

private fun deriveRev(fullCode: String): String? {
val v = Regex("^.*V(\\d+)$").find(fullCode) ?: return null
return v.groupValues[1]
}

private fun resolveFlowTypeId(code: String): Int = when {
code.startsWith("TOA") -> 1
code.startsWith("BOMPP") || code.startsWith("PP") -> 3


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt Bestand weergeven

@@ -495,6 +495,9 @@ open class BomService(
m18BomShopSyncLogRepository.save(
M18BomShopSyncLog().apply {
this.bomId = bomId
finishedItemCode = req.udfbomforshop.values.firstOrNull()?.udfBomCode
m18HeaderCode = req.udfbomforshop.values.firstOrNull()?.code
requestFingerprint = m18BomForShopService.contentFingerprint(req)
m18RecordId = recordId.takeIf { it > 0 }
m18ApiStatus = apiStatus
synced = result.synced


+ 32
- 0
src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt Bestand weergeven

@@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.purchaseOrder.entity
import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderLineInfo
import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderLineStatus
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
@@ -10,6 +11,37 @@ import java.io.Serializable

@Repository
interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Long> {
@Query(
"SELECT pol FROM PurchaseOrderLine pol " +
"LEFT JOIN FETCH pol.purchaseOrder po " +
"LEFT JOIN FETCH po.supplier " +
"JOIN FETCH pol.uom " +
"LEFT JOIN FETCH pol.uomM18 " +
"WHERE pol.deleted = false AND pol.item.id = :itemId AND pol.m18DataLog IS NOT NULL " +
"ORDER BY pol.created DESC",
)
fun findLatestLinesForBomM18ByItemId(
@Param("itemId") itemId: Long,
pageable: Pageable,
): List<PurchaseOrderLine>

/**
* Latest PO (by header `purchase_order.created`) for a material item code: supplier `shop.m18Id` from `purchase_order.supplierId`.
* Mirrors manual SQL: pol → items (code), po, shop on supplier, uom_conversion; order by po.created desc limit 1.
*/
@Query(
value =
"SELECT sh.m18Id FROM purchase_order_line pol " +
"LEFT JOIN items it ON pol.itemId = it.id " +
"LEFT JOIN purchase_order po ON pol.purchaseOrderId = po.id " +
"LEFT JOIN shop sh ON po.supplierId = sh.id " +
"LEFT JOIN uom_conversion um ON pol.uomIdM18 = um.id " +
"WHERE pol.deleted = false AND it.deleted = false AND it.code = :itemCode " +
"ORDER BY po.created DESC LIMIT 1",
nativeQuery = true,
)
fun findLatestPoSupplierM18IdByItemCodeNative(@Param("itemCode") itemCode: String): List<Long>

fun findByM18DataLogIdAndDeletedIsFalse(m18datalogId: Serializable): PurchaseOrderLine?
fun findAllPurchaseOrderLineInfoByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLineInfo>
fun findAllByPurchaseOrderIdAndDeletedIsFalse(purchaseOrderId: Long): List<PurchaseOrderLine>


+ 1
- 1
src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql Bestand weergeven

@@ -9,7 +9,7 @@ WHERE NOT EXISTS (
);

INSERT INTO `fpsmsdb`.`settings` (`name`, `value`, `category`, `type`)
SELECT 'SCHEDULE.m18.do2', '0 0 13 * * *', 'SCHEDULE', 'string'
SELECT 'SCHEDULE.m18.do2', '0 0 11 * * *', 'SCHEDULE', 'string'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `fpsmsdb`.`settings` WHERE `name` = 'SCHEDULE.m18.do2'

+ 7
- 0
src/main/resources/db/changelog/changes/20260515_fpsms/01_m18_bom_shop_sync_log_columns.sql Bestand weergeven

@@ -0,0 +1,7 @@
--liquibase formatted sql
--changeset fpsms:20260515_m18_bom_shop_sync_log_columns

ALTER TABLE `m18_bom_shop_sync_log`
ADD COLUMN `finished_item_code` VARCHAR(100) NULL COMMENT 'BOM finished-good item code' AFTER `bom_id`,
ADD COLUMN `m18_header_code` VARCHAR(200) NULL COMMENT 'M18 header code BOM+item+Vnnn' AFTER `finished_item_code`,
ADD COLUMN `request_fingerprint` VARCHAR(64) NULL COMMENT 'SHA-256 of normalized payload for change detection' AFTER `m18_header_code`;

Laden…
Annuleren
Opslaan