Kaynağa Gözat

updated Bag3, adding m18 BOM syn, delete the DO2_EXTRA syn, move the DO2 sync to 1pm

production
ebeveyn
işleme
4b83633f28
22 değiştirilmiş dosya ile 712 ekleme ve 54 silme
  1. +78
    -19
      python/Bag3.py
  2. BIN
      python/__pycache__/Bag3.cpython-313.pyc
  3. +39
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt
  4. +5
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt
  5. +86
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt
  6. +14
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt
  7. +223
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt
  8. +11
    -1
      src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt
  9. +5
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  10. +12
    -30
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  11. +6
    -3
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  12. +141
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
  13. +14
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  14. +12
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt
  15. +14
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt
  16. +1
    -1
      src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql
  17. +20
    -0
      src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql
  18. +24
    -0
      src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql
  19. +4
    -0
      src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql
  20. +1
    -0
      src/main/resources/log4j2-prod-linux.yml
  21. +1
    -0
      src/main/resources/log4j2-prod-win.yml
  22. +1
    -0
      src/main/resources/log4j2.yml

+ 78
- 19
python/Bag3.py Dosyayı Görüntüle

@@ -16,6 +16,7 @@ Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3.
Run: python Bag3.py
"""

import errno
import json
import os
import select
@@ -344,6 +345,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max(
DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env(
"FPSMS_DATAFLEX_SINGLE_TCP_JOB", False
)
# Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION").
# Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0
DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env(
"FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True
)
# Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin.
DATAFLEX_LABEL_PW = max(
280,
_dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400),
)
DATAFLEX_LABEL_LL = max(
200,
_dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500),
)
# Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054.
# Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST).
DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env(
"FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False
)
# Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware
DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2
# Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery)
@@ -364,12 +384,56 @@ def _zpl_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("^", "\\^")


def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str:
"""
Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job
under the job order id instead of a generic raw-ZPL label.
"""
if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None:
return ""
try:
jid = str(int(job_order_id))
except (TypeError, ValueError):
return ""
if not jid.isdigit():
return ""
return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n'


def _dataflex_zpl_bytes(zpl: str) -> bytes:
"""UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary."""
s = (zpl or "").rstrip("\r\n")
return (s + "\r\n").encode("utf-8")


def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool:
"""True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054)."""
if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)):
return True
if isinstance(err, OSError):
if getattr(err, "winerror", None) == 10054: # WSAECONNRESET
return True
if err.errno in (
errno.ECONNRESET,
errno.EPIPE,
errno.ECONNABORTED,
):
return True
return False


def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None:
"""Half-close write side; ignore printer RST (common after ZPL on port 9100-style links)."""
if DATAFLEX_SKIP_SHUTDOWN_WR:
return
try:
sock.shutdown(socket.SHUT_WR)
except OSError as e:
if _dataflex_is_benign_tcp_reset(e):
return
raise


def generate_zpl_dataflex(
batch_no: str,
item_code: str,
@@ -377,6 +441,7 @@ def generate_zpl_dataflex(
item_id: Optional[int] = None,
stock_in_line_id: Optional[int] = None,
lot_no: Optional[str] = None,
job_order_id: Optional[int] = None,
font_regular: str = "E:STXihei.ttf",
font_bold: str = "E:STXihei.ttf",
) -> str:
@@ -398,11 +463,12 @@ def generate_zpl_dataflex(
qr_value = _zpl_escape(qr_payload)
# Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex
# firmware when many labels are sent on one TCP session without a per-job quantity.
return f"""^XA
host_id = _dataflex_host_identification_sgd_prefix(job_order_id)
return host_id + f"""^XA
^PQ1,0,1,N
^CI28
^PW700
^LL500
^PW{DATAFLEX_LABEL_PW}
^LL{DATAFLEX_LABEL_LL}
^PO N
^FO10,20
^BQN,2,4^FDQA,{qr_value}^FS
@@ -447,10 +513,7 @@ def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) ->
sock.connect((ip, port))
sock.sendall(DATAFLEX_PREPRINT_BYTES)
time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC)
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
_dataflex_shutdown_write_maybe(sock)
finally:
sock.close()

@@ -472,10 +535,7 @@ def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False)
sock.connect((ip, port))
sock.sendall(_dataflex_full_recovery_payload())
time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC)
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
_dataflex_shutdown_write_maybe(sock)
finally:
sock.close()

@@ -527,10 +587,7 @@ def send_dataflex_reset_and_labels(
time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC)
if i < copies - 1:
time.sleep(delay_sec)
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
_dataflex_shutdown_write_maybe(sock)
finally:
sock.close()

@@ -879,10 +936,7 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None:
sock.connect((ip, port))
sock.sendall(_dataflex_zpl_bytes(zpl))
time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC)
try:
sock.shutdown(socket.SHUT_WR)
except OSError:
pass
_dataflex_shutdown_write_maybe(sock)
finally:
sock.close()

@@ -907,6 +961,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str:
data = sock.recv(4096)
except socket.timeout:
break
except OSError as ex:
if _dataflex_is_benign_tcp_reset(ex):
break
raise
if not data:
break
chunks.append(data)
@@ -2451,6 +2509,7 @@ def main() -> None:
item_id=item_id,
stock_in_line_id=stock_in_line_id,
lot_no=lot_no,
job_order_id=j.get("id"),
)
label_text = (lot_no or b).strip()
if continuous:


BIN
python/__pycache__/Bag3.cpython-313.pyc Dosyayı Görüntüle


+ 39
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLog.kt Dosyayı Görüntüle

@@ -0,0 +1,39 @@
package com.ffii.fpsms.m18.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull

/**
* Audit log for FPSMS → M18 udfBomForShop sync (request / response bodies).
*/
@Entity
@Table(name = "m18_bom_shop_sync_log")
open class M18BomShopSyncLog : BaseEntity<Long>() {

@NotNull
@Column(name = "bom_id", nullable = false)
open var bomId: Long? = null

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

@NotNull
@Column(name = "m18_api_status", nullable = false)
open var m18ApiStatus: Boolean = false

@NotNull
@Column(name = "synced", nullable = false)
open var synced: Boolean = false

@Column(name = "message", length = 4000)
open var message: String? = null

@Column(name = "request_json", columnDefinition = "LONGTEXT")
open var requestJson: String? = null

@Column(name = "response_json", columnDefinition = "LONGTEXT")
open var responseJson: String? = null
}

+ 5
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt Dosyayı Görüntüle

@@ -0,0 +1,5 @@
package com.ffii.fpsms.m18.entity

import com.ffii.core.support.AbstractRepository

interface M18BomShopSyncLogRepository : AbstractRepository<M18BomShopSyncLog, Long>

+ 86
- 0
src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt Dosyayı Görüntüle

@@ -0,0 +1,86 @@
package com.ffii.fpsms.m18.model

import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty

/**
* M18 save payload for Shop BOM (udfBomForShop).
* PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop
*
* Same idea as GRN (`mainan` + `ant`): header and lines each wrapped as `{ "values": [ ... ] }`.
* Root keys: **`udfbomforshop`** and **`udfproduct`** (same as M18 read [M18BomData]).
* (Spelling is **udf**, not "uni".)
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18BomForShopSaveRequest(
@JsonProperty("udfbomforshop")
val udfbomforshop: M18MainUdfBomForShopWrapper,
@JsonProperty("udfproduct")
val udfproduct: M18UdfProductWrapper,
)

@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18MainUdfBomForShopWrapper(
val values: List<M18MainUdfBomForShopValue>,
)

/**
* Header row for udfBomForShop. Field names match M18 read/sample JSON.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18MainUdfBomForShopValue(
/**
* Existing M18 udfBomForShop header id for **update** (same as FPSMS [Bom.m18Id] after first sync).
* Omit or null for **create**. Sent as JSON string for M18 compatibility (like GRN mainan `id`).
*/
val id: String? = null,
val code: String? = null,
val beId: Int? = null,
val desc: String? = null,
@JsonProperty("desc_en")
val descEn: String? = null,
@JsonProperty("udfBOMCode")
val udfBomCode: String? = null,
val rev: String? = null,
val udfUnit: Long? = null,
/** Harvest qty: [Bom.outputQty] × pack multiple from header item stock UOM code (e.g. PACK2LB → ×2), else plain output qty. */
val udfHarvest: String? = null,
/** Trailing unit letters from that code (e.g. LB); null if code not parsed. */
val udfHarvestUnit: String? = null,
/** Epoch milliseconds (M18-style; same as read `lastModifyDate`). From FPSMS [com.ffii.core.entity.BaseEntity.created] in Asia/Hong_Kong. */
@JsonProperty("udfeffectivedate")
val udfEffectiveDate: Long? = null,
@JsonProperty("udfYieldratePP")
val udfYieldratePP: Number? = null,
val udftypeoffood: String? = null,
val staffId: Int? = null,
val flowTypeId: Int? = null,
val virDeptId: Int? = null,
val status: String? = null,
)

@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18UdfProductWrapper(
val values: List<M18UdfProductSaveValue>,
)

/**
* Line payload for `udfproduct.values[]`. **`udfBaseUnit`** is the FPSMS UOM **code** for the line.
* **`udfpurchaseUnit`** / **`udfPackingUnit`** / **`udfPackingQty`** / **`udfproremark`** are not sent.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
data class M18UdfProductSaveValue(
/** Line id in M18 when updating */
val id: Long? = null,
val udfqty: Number? = null,
val udfProduct: Long? = null,
val udfIngredients: String? = null,
/** Line UOM: [com.ffii.fpsms.modules.master.entity.UomConversion.code] (same unit as [udfqty]). */
val udfBaseUnit: String? = null,
val udfSupplier: Long? = null,
/** Line sequence, e.g. " 1" */
val itemNo: String? = null,
val udfoptions: String? = null,
val udfoption: Number? = null,
val udfYieldRate: Number? = null,
)

+ 14
- 0
src/main/java/com/ffii/fpsms/m18/model/M18BomShopSyncTriggerResult.kt Dosyayı Görüntüle

@@ -0,0 +1,14 @@
package com.ffii.fpsms.m18.model

/**
* Result of [com.ffii.fpsms.modules.master.service.BomService.pushBomToM18ShopIfAllowed]
* (e.g. POST /m18/test/bom-shop-sync/{bomId}).
*/
data class M18BomShopSyncTriggerResult(
val bomId: Long,
val synced: Boolean,
val skippedReason: String? = null,
val recordId: Long? = null,
val status: Boolean? = null,
val messageSummary: String? = null,
)

+ 223
- 0
src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt Dosyayı Görüntüle

@@ -0,0 +1,223 @@
package com.ffii.fpsms.m18.service

import com.fasterxml.jackson.core.JsonGenerator
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.model.GoodsReceiptNoteResponse
import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest
import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue
import com.ffii.fpsms.m18.model.M18MainUdfBomForShopWrapper
import com.ffii.fpsms.m18.model.M18UdfProductSaveValue
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 org.slf4j.Logger
import org.slf4j.LoggerFactory
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.time.ZoneId

/**
* Push FPSMS BOM + materials to M18 udfBomForShop (similar to GRN save/an).
* PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop
*/
@Service
open class M18BomForShopService(
private val m18Config: M18Config,
private val apiCallerService: ApiCallerService,
private val itemUomService: ItemUomService,
) {
private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java)

private val savePath = "/root/api/save/udfbomforshop"
private val menuCode = "udfbomforshop"

/** M18 business entity id for udfBomForShop header (`udfbomforshop.values[0].beId`). */
private val bomShopMainBeId: Int = 29

/**
* Stock UOM `code` on the **BOM header item** (e.g. PACK2LB = prefix + pack multiple + unit suffix).
* [udfHarvest] = [Bom.outputQty] × middle number; [udfHarvestUnit] = trailing unit (e.g. LB).
*/
private val bomItemStockUomPackCodeRegex = Regex("^([A-Za-z]+)(\\d+)([A-Za-z]+)$")

companion object {
private const val HARVEST_CALC_SCALE = 10
private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong")
}

@Suppress("DEPRECATION")
private val objectMapper: ObjectMapper = jacksonObjectMapper().apply {
disable(JsonGenerator.Feature.ESCAPE_NON_ASCII)
}

/**
* 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).
*/
open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? {
val code = bom.code ?: return null
val flowTypeId = resolveFlowTypeId(code)
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 header = M18MainUdfBomForShopValue(
id = effectiveHeaderM18Id?.toString(),
code = code,
beId = bomShopMainBeId,
desc = bom.name ?: bom.description,
descEn = bom.name ?: bom.description,
udfBomCode = deriveUdfBomCode(code),
rev = deriveRev(code),
udfUnit = udfUnit,
udfHarvest = udfHarvest,
udfHarvestUnit = udfHarvestUnit,
udfEffectiveDate = udfEffectiveDate,
udfYieldratePP = bom.yield,
udftypeoffood = "半成品",
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})",
)

return M18BomForShopSaveRequest(
udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(header)),
udfproduct = M18UdfProductWrapper(values = lines),
)
}

/**
* 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.
* Falls back to plain [outputQty] and null unit when item/stock UOM/code is missing or does not match.
*/
private fun resolveUdfHarvestFields(bom: Bom, outputQty: BigDecimal): Pair<String, String?> {
val itemId = bom.item?.id
if (itemId == null) {
logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}")
return outputQty.stripTrailingZeros().toPlainString() to null
}
val stockCode = itemUomService.findStockUnitByItemId(itemId)?.uom?.code?.trim().orEmpty()
if (stockCode.isEmpty()) {
logger.warn("[M18 BOM] stock UOM code missing for bom itemId=$itemId; udfHarvest=outputQty only. bomId=${bom.id}")
return outputQty.stripTrailingZeros().toPlainString() to null
}
val match = bomItemStockUomPackCodeRegex.matchEntire(stockCode)
if (match == null) {
logger.warn(
"[M18 BOM] stock UOM code '$stockCode' does not match PREFIX+NUMBER+SUFFIX; " +
"udfHarvest=outputQty only. bomId=${bom.id} itemId=$itemId",
)
return outputQty.stripTrailingZeros().toPlainString() to null
}
val mult = match.groupValues[2].toBigDecimalOrNull()
if (mult == null || mult.compareTo(BigDecimal.ZERO) <= 0) {
logger.warn(
"[M18 BOM] invalid pack multiple in stock UOM code '$stockCode'; udfHarvest=outputQty only. bomId=${bom.id}",
)
return outputQty.stripTrailingZeros().toPlainString() to null
}
val unitSuffix = match.groupValues[3]
val harvestQty = outputQty.multiply(mult).setScale(HARVEST_CALC_SCALE, RoundingMode.HALF_UP).stripTrailingZeros()
return harvestQty.toPlainString() to unitSuffix
}

private fun toProductLine(mat: BomMaterial, lineNo: Int): M18UdfProductSaveValue? {
val proId = mat.item?.m18Id?.takeIf { it > 0 } ?: run {
logger.warn("[M18 BOM] material item m18Id missing bomMaterialId=${mat.id} itemId=${mat.item?.id}")
return null
}
val udfBaseUnit = mat.uom?.code?.trim()?.takeIf { it.isNotEmpty() } ?: run {
logger.warn("[M18 BOM] material UOM code missing bomMaterialId=${mat.id}")
return null
}
val udfqty = (mat.qty ?: BigDecimal.ZERO).setScale(8, RoundingMode.HALF_UP).toDouble()
return M18UdfProductSaveValue(
id = mat.m18Id?.takeIf { it > 0 },
udfqty = udfqty,
udfProduct = proId,
udfIngredients = mat.itemName ?: mat.item?.name,
udfBaseUnit = udfBaseUnit,
udfSupplier = 0L,
itemNo = String.format("%6d", lineNo),
udfoptions = "",
udfoption = 0.0,
udfYieldRate = 0.0,
)
}

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
code.startsWith("BOMPF") || code.startsWith("PF") || code.startsWith("PFP") -> 2
else -> 1
}

open fun toJson(request: M18BomForShopSaveRequest): String =
objectMapper.writeValueAsString(request)

open fun toJson(response: GoodsReceiptNoteResponse): String =
objectMapper.writeValueAsString(response)

open fun saveBomForShop(request: M18BomForShopSaveRequest): GoodsReceiptNoteResponse? =
saveBomForShopMono(request).block()

open fun saveBomForShopMono(request: M18BomForShopSaveRequest): Mono<GoodsReceiptNoteResponse> {
val queryParams = LinkedMultiValueMap<String, String>().apply {
add("menuCode", menuCode)
}
val qs = queryParams.entries.flatMap { (k, v) -> v.map { "$k=$it" } }.joinToString("&")
val fullUrl = "${m18Config.BASE_URL}$savePath?$qs"
val bodyJson = objectMapper.writeValueAsString(request)
logger.info("[M18 BOM udfBomForShop] PUT url=$fullUrl bodyUtf8Bytes=${bodyJson.toByteArray(StandardCharsets.UTF_8).size}")
logger.debug("[M18 BOM udfBomForShop] PUT body=$bodyJson")
return apiCallerService.putWithJsonString<GoodsReceiptNoteResponse>(
urlPath = savePath,
queryParams = queryParams,
bodyJson = bodyJson,
).doOnSuccess { r ->
logger.info("[M18 BOM udfBomForShop] response status=${r.status} recordId=${r.recordId} messages=${r.messages}")
}.doOnError { e ->
logger.error("[M18 BOM udfBomForShop] failed: ${e.message}", e)
}
}
}

+ 11
- 1
src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt Dosyayı Görüntüle

@@ -4,8 +4,9 @@ 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.model.M18BomShopSyncTriggerResult
import com.ffii.fpsms.m18.web.models.M18CommonRequest
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.master.service.BomService
import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService
import com.ffii.fpsms.modules.master.entity.ItemUom
import com.ffii.fpsms.modules.master.entity.Items
@@ -35,6 +36,7 @@ class M18TestController (
private val m18DeliveryOrderService: M18DeliveryOrderService,
val schedulerService: SchedulerService,
private val settingsService: SettingsService,
private val bomService: BomService,
) {
var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java)

@@ -65,6 +67,14 @@ class M18TestController (
return schedulerService.getM18Pos();
}

@PostMapping("/test/bom-shop-sync/{bomId}")
fun testBomShopSync(
@PathVariable bomId: Long,
@RequestParam(required = false) m18HeaderId: Long?,
): M18BomShopSyncTriggerResult {
return bomService.pushBomToM18ShopIfAllowed(bomId, m18HeaderId)
}

@GetMapping("/test/po-by-code")
fun testSyncPoByCode(@RequestParam code: String): SyncResult {
return m18PurchaseOrderService.savePurchaseOrderByCode(code)


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java Dosyayı Görüntüle

@@ -41,6 +41,11 @@ public abstract class SettingNames {
*/
public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone";

/**
* When "true", FPSMS may push BOM header + materials to M18 udfBomForShop.
*/
public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled";

/** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */
public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn";



+ 12
- 30
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt Dosyayı Görüntüle

@@ -57,6 +57,12 @@ open class SchedulerService(
val m18GrnCodeSyncService: M18GrnCodeSyncService,
val inventoryLotLineService: InventoryLotLineService,
) {
companion object {
/** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */
const val DO2_MODIFIED_TO_HOUR: Int = 13
const val DO2_DEFAULT_CRON: String = "0 0 13 * * *"
}

var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java)
val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
@@ -206,7 +212,7 @@ open class SchedulerService(
logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)")
return
}
scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, ::getM18Dos2)
scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2)
}

fun scheduleM18MasterData() {
@@ -455,7 +461,7 @@ open class SchedulerService(
val ysd = today.minusDays(1L)
val tmr = today.plusDays(1L)

// Default: lastModified from yesterday 19:00 (aligns with nightly DO2 expectation).
// Default: lastModified from yesterday 19:00 through today's DO2 run hour (1pm; aligns with SCHEDULE.m18.do2).
// On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1
// (otherwise Sat 03:00–18:59 would be skipped until a much later sync).
val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY
@@ -465,21 +471,21 @@ open class SchedulerService(
ysd.withHour(19).withMinute(0).withSecond(0)
}

// Set to 11:00:00 of today
val todayEleven = today.withHour(11).withMinute(0).withSecond(0)
val modifiedDateToEnd =
today.withHour(DO2_MODIFIED_TO_HOUR).withMinute(0).withSecond(0)

logger.info(
"DO2 modifiedDateFrom={} ({}), modifiedDateTo={}",
modifiedFromStart.format(dateTimeStringFormat),
if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00",
todayEleven.format(dateTimeStringFormat),
modifiedDateToEnd.format(dateTimeStringFormat),
)

val requestDO = M18CommonRequest(
// These will now produce "yyyy-MM-dd HH:mm:ss"
dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00
dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00
modifiedDateTo = todayEleven.format(dateTimeStringFormat), // 2026-01-18 11:00:00
modifiedDateTo = modifiedDateToEnd.format(dateTimeStringFormat),
modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat),
)
@@ -491,30 +497,6 @@ open class SchedulerService(
result = result,
start = currentTime
)

// Extra DO sync window: after DO2, also sync ETA = today or tomorrow (normal sync; does NOT set isEtra).
try {
val extraStart = LocalDateTime.now()
val requestExtra = M18CommonRequest(
dDateFrom = today.format(dateTimeStringFormat),
dDateTo = tmr.format(dateTimeStringFormat),
)
val extraResult = m18DeliveryOrderService.saveDeliveryOrders(requestExtra)
saveSyncLog(
type = "DO2_EXTRA",
status = "SUCCESS",
result = extraResult,
start = extraStart,
)
} catch (e: Exception) {
logger.error("DO2_EXTRA sync failed: ${e.message}", e)
saveSyncLog(
type = "DO2_EXTRA",
status = "FAIL",
error = e.message,
start = LocalDateTime.now(),
)
}
}

open fun getPostCompletedDnAndProcessGrn(


+ 6
- 3
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt Dosyayı Görüntüle

@@ -1367,15 +1367,18 @@ class PlasticBagPrinterService(
}
val qrValue = zplEscape(qrPayload)

// Must match python Bag2.py generate_zpl_dataflex()
// Must match python Bag3.py generate_zpl_dataflex() field layout / fonts.
val fontRegular = "E:STXihei.ttf"
val fontBold = "E:STXihei.ttf"

// Match python Bag3.py DataFlex defaults: narrower ^PW so job preview is not mostly empty on the right (^A@R fields are tall, not wide).
val labelPw = 400
val labelLl = 500
return """
^XA
^CI28
^PW700
^LL500
^PW$labelPw
^LL$labelLl
^PO N
^FO10,20
^BQN,2,4^FDQA,$qrValue^FS


+ 141
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt Dosyayı Görüntüle

@@ -34,6 +34,15 @@ import java.util.Comparator
import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository
import org.springframework.transaction.annotation.Transactional
import com.fasterxml.jackson.databind.ObjectMapper
import com.ffii.fpsms.m18.service.M18BomForShopService
import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult
import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse
import com.ffii.fpsms.m18.entity.M18BomShopSyncLog
import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.settings.entity.Settings
import com.ffii.fpsms.modules.settings.service.SettingsService

@Service
open class BomService(
@@ -52,6 +61,10 @@ open class BomService(
private val itemUomService: ItemUomService,
private val jobOrderRepository: JobOrderRepository,
private val productProcessRepository: ProductProcessRepository,
private val m18BomForShopService: M18BomForShopService,
private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository,
private val objectMapper: ObjectMapper,
private val settingsService: SettingsService,
@Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String,
) {
open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse {
@@ -119,6 +132,29 @@ open class BomService(
?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull()
}

/** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */
open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse {
val code = itemCodeTrimmed.trim()
val item = itemsRepository.findByCodeAndDeletedFalse(code)
?: return BomIdByItemCodeResponse(
itemCode = code,
message = "Item not found for code",
)
val bom = findByItemId(item.id!!)
?: return BomIdByItemCodeResponse(
itemCode = code,
itemId = item.id,
message = "No BOM linked to this item",
)
return BomIdByItemCodeResponse(
itemCode = code,
itemId = item.id,
bomId = bom.id,
bomCode = bom.code,
bomM18Id = bom.m18Id,
)
}

open fun saveBom(request: SaveBomRequest): SaveBomResponse {

val item = request.code.let { itemsService.findByM18BomCode(it) } ?: request.itemId?.let { itemsService.findById(it) }
@@ -371,6 +407,111 @@ open class BomService(
return getBomDetail(bom.id!!)
}

/**
* When {@link SettingNames#M18_BOM_SHOP_SYNC_ENABLED} is true, push BOM to M18 udfBomForShop.
* Use {@code POST /m18/test/bom-shop-sync/{bomId}} (or future UI) to trigger explicitly.
* Optional [m18HeaderId]: M18 udfBomForShop **header** record id (e.g. [BomIdByItemCodeResponse.bomM18Id])
* to force **update** when [Bom.m18Id] is missing or stale. When null, uses [Bom.m18Id] if set.
*/
open fun pushBomToM18ShopIfAllowed(bomId: Long, m18HeaderId: Long? = null): M18BomShopSyncTriggerResult {
if (!isM18BomShopSyncEnabled()) {
return M18BomShopSyncTriggerResult(
bomId = bomId,
synced = false,
skippedReason = "M18 BOM shop sync disabled (${SettingNames.M18_BOM_SHOP_SYNC_ENABLED} is not true)",
)
}
val bom = bomRepository.findByIdAndDeletedIsFalse(bomId)
?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "BOM not found")
val req = m18BomForShopService.buildSaveRequest(bom, m18HeaderId)
?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "Cannot build M18 payload (missing m18 UOM/item ids or no materials)")

val requestJsonPayload = m18BomForShopService.toJson(req)
var resp: GoodsReceiptNoteResponse? = null
var callError: Throwable? = null
try {
resp = m18BomForShopService.saveBomForShop(req)
} catch (e: Exception) {
callError = e
}

val responseJsonPayload = when {
resp != null -> m18BomForShopService.toJson(resp)
callError != null ->
runCatching {
objectMapper.writeValueAsString(
mapOf(
"exceptionType" to callError.javaClass.name,
"message" to (callError.message ?: ""),
),
)
}.getOrElse { """{"error":"failed to serialize exception"}""" }
else -> """{"error":"M18 API returned null"}"""
}

val msgSummary = resp?.messages?.joinToString("; ") { it.msgDetail ?: it.msgCode ?: "" }.orEmpty()
val apiStatus = resp?.status == true
val recordId = resp?.recordId ?: 0L

val result = when {
callError != null ->
M18BomShopSyncTriggerResult(
bomId = bomId,
synced = false,
skippedReason = callError.message ?: "M18 API call failed",
status = false,
messageSummary = callError.message,
)
resp == null ->
M18BomShopSyncTriggerResult(bomId, false, skippedReason = "M18 API returned null")
resp.status == true && resp.recordId > 0L -> {
bom.m18Id = resp.recordId
bomRepository.saveAndFlush(bom)
M18BomShopSyncTriggerResult(
bomId = bomId,
synced = true,
recordId = resp.recordId,
status = true,
messageSummary = msgSummary.ifBlank { null },
)
}
else ->
M18BomShopSyncTriggerResult(
bomId = bomId,
synced = false,
skippedReason = "M18 save failed or status=false",
recordId = resp.recordId.takeIf { it > 0 },
status = resp.status,
messageSummary = msgSummary.ifBlank { null },
)
}

val logMessage = listOfNotNull(
msgSummary.ifBlank { null },
callError?.message,
result.skippedReason?.takeIf { !result.synced },
).joinToString("; ").take(4000)

m18BomShopSyncLogRepository.save(
M18BomShopSyncLog().apply {
this.bomId = bomId
m18RecordId = recordId.takeIf { it > 0 }
m18ApiStatus = apiStatus
synced = result.synced
message = logMessage.ifBlank { null }
requestJson = requestJsonPayload
responseJson = responseJsonPayload
},
)

return result
}

private fun isM18BomShopSyncEnabled(): Boolean =
settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED)
.map { Settings.VALUE_BOOLEAN_TRUE == it.value }
.orElse(false)

private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment {
val equipmentId = pReq.equipmentId
val equipmentCode = pReq.equipmentCode?.trim().orEmpty()


+ 14
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt Dosyayı Görüntüle

@@ -282,6 +282,20 @@ open class ItemUomService(
return finalizePreciseStockQty(stockUnit, stockQty)
}

/**
* Convert quantity from [uomId] (must exist on `item_uom` for [itemId]) to the item's **base unit** quantity.
* Returns null when no `item_uom` row links the item to that UOM.
*/
open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return null
val one = BigDecimal.ONE
val calcScale = 10
return sourceQty
.multiply(itemUom.ratioN ?: one)
.divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP)
.stripTrailingZeros()
}

// See if need to update the response
open fun saveItemUom(request: ItemUomRequest): ItemUom {
val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) }


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt Dosyayı Görüntüle

@@ -29,6 +29,8 @@ import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload
import com.ffii.fpsms.modules.master.web.models.BomDetailResponse
import com.ffii.fpsms.modules.master.web.models.EditBomRequest
import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress
import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse
import com.ffii.core.exception.BadRequestException
import java.util.logging.Logger
import java.nio.file.Files
import org.springframework.core.io.FileSystemResource
@@ -120,6 +122,16 @@ fun downloadBomFormatIssueLog(
// fun exportProblematicBom() {
// return bomService.importBOM()
// }

/** Testing: FPSMS BOM id by finished-good item code (same item as BOM header). */
@GetMapping("/by-item-code")
fun getBomByItemCode(@RequestParam code: String): BomIdByItemCodeResponse {
if (code.isBlank()) {
throw BadRequestException("query parameter code is required")
}
return bomService.findBomSummaryByItemCode(code.trim())
}

@GetMapping("/{id}/detail")
fun getBomDetail(@PathVariable id: Long): BomDetailResponse {
return bomService.getBomDetail(id)


+ 14
- 0
src/main/java/com/ffii/fpsms/modules/master/web/models/BomIdByItemCodeResponse.kt Dosyayı Görüntüle

@@ -0,0 +1,14 @@
package com.ffii.fpsms.modules.master.web.models

/**
* Testing / lookup: resolve FPSMS BOM from finished-good [item] code (bom.item → [Items.code]).
*/
data class BomIdByItemCodeResponse(
val itemCode: String,
val itemId: Long? = null,
val bomId: Long? = null,
val bomCode: String? = null,
val bomM18Id: Long? = null,
/** e.g. item not found, or no BOM for item */
val message: String? = null,
)

+ 1
- 1
src/main/resources/db/changelog/changes/20260118_fai/01_insert_scheduler.sql Dosyayı Görüntüle

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

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

+ 20
- 0
src/main/resources/db/changelog/changes/20260512_fai/01_m18_bom_shop_sync_settings.sql Dosyayı Görüntüle

@@ -0,0 +1,20 @@
--liquibase formatted sql
--changeset fai:20260512_m18_bom_shop_sync_settings

INSERT INTO `settings` (`name`, `value`, `category`, `type`)
SELECT v.name, v.value, v.category, v.type
FROM (
SELECT 'M18.bom.shop.sync.enabled' AS name, 'false' AS value, 'M18' AS category, 'boolean' AS type
) v
WHERE NOT EXISTS (
SELECT 1 FROM `settings` s WHERE s.name = 'M18.bom.shop.sync.enabled'
);

INSERT INTO `settings` (`name`, `value`, `category`, `type`)
SELECT v.name, v.value, v.category, v.type
FROM (
SELECT 'M18.bom.shop.sync.allowedBomIds' AS name, '78,274' AS value, 'M18' AS category, 'string' AS type
) v
WHERE NOT EXISTS (
SELECT 1 FROM `settings` s WHERE s.name = 'M18.bom.shop.sync.allowedBomIds'
);

+ 24
- 0
src/main/resources/db/changelog/changes/20260512_fai/02_m18_bom_shop_sync_log.sql Dosyayı Görüntüle

@@ -0,0 +1,24 @@
--liquibase formatted sql
--changeset fai:20260512_m18_bom_shop_sync_log

CREATE TABLE IF NOT EXISTS `m18_bom_shop_sync_log` (
`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,
`modifiedBy` VARCHAR(30) NULL DEFAULT NULL,
`deleted` TINYINT(1) NOT NULL DEFAULT '0',

`bom_id` BIGINT NOT NULL COMMENT 'FPSMS bom.id',
`m18_record_id` BIGINT NULL DEFAULT NULL COMMENT 'M18 udfBomForShop record id when returned',
`m18_api_status` TINYINT(1) NOT NULL COMMENT 'M18 response status field',
`synced` TINYINT(1) NOT NULL COMMENT 'FPSMS treat as success (e.g. updated bom.m18Id)',
`message` VARCHAR(4000) NULL DEFAULT NULL COMMENT 'Summary / errors',
`request_json` LONGTEXT NULL COMMENT 'PUT body sent to M18',
`response_json` LONGTEXT NULL COMMENT 'Parsed M18 response or error JSON',

CONSTRAINT pk_m18_bom_shop_sync_log PRIMARY KEY (`id`),
KEY `idx_m18_bom_shop_sync_log_bom_id` (`bom_id`),
KEY `idx_m18_bom_shop_sync_log_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

+ 4
- 0
src/main/resources/db/changelog/changes/20260513_do2_1pm/01_update_do2_schedule_to_13.sql Dosyayı Görüntüle

@@ -0,0 +1,4 @@
--liquibase formatted sql
--changeset fpsms:20260513_do2_schedule_1pm

UPDATE `settings` SET `value` = '0 0 13 * * *' WHERE `name` = 'SCHEDULE.m18.do2';

+ 1
- 0
src/main/resources/log4j2-prod-linux.yml Dosyayı Görüntüle

@@ -11,6 +11,7 @@ Configutation:
filePattern: ${log_location}fpsms-all.log.%i.gz
PatternLayout:
Pattern: "%d %p [%l] - %m%n"
charset: UTF-8
Policies:
SizeBasedTriggeringPolicy:
size: 4096KB


+ 1
- 0
src/main/resources/log4j2-prod-win.yml Dosyayı Görüntüle

@@ -11,6 +11,7 @@ Configutation:
filePattern: ${log_location}fpsms-all.log.%i.gz
PatternLayout:
Pattern: "%d %p [%l] - %m%n"
charset: UTF-8
Policies:
SizeBasedTriggeringPolicy:
size: 4096KB


+ 1
- 0
src/main/resources/log4j2.yml Dosyayı Görüntüle

@@ -10,6 +10,7 @@ Configutation:
target: SYSTEM_OUT
PatternLayout:
pattern: ${log_pattern}
charset: UTF-8
Loggers:
Root:
level: info


Yükleniyor…
İptal
Kaydet