瀏覽代碼

added for expiry date for bag printing and handle the changed id for item m18Id

fix負數倉
Fai Luk 4 週之前
父節點
當前提交
42ac8cf6f8
共有 45 個檔案被更改,包括 6027 行新增120 行删除
  1. +4
    -0
      build.gradle
  2. +85
    -0
      docs/deploy/20260819_po-m18id-remap.md
  3. +3074
    -0
      python/Bag4.py
  4. +38
    -0
      python/Bag4.spec
  5. 二進制
      python/__pycache__/Bag4.cpython-314.pyc
  6. +17
    -11
      python/installAndExe.txt
  7. +186
    -0
      scripts/generate_item_default_shelf_life_liquibase.py
  8. +28
    -16
      src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt
  9. +41
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt
  10. +29
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt
  11. +1
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt
  12. +45
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt
  13. +152
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt
  14. +61
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt
  15. +364
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt
  16. +13
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt
  17. +306
    -62
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  18. +55
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt
  19. +91
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt
  20. +7
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt
  21. +29
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt
  22. +2
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt
  23. +43
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt
  24. +12
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt
  25. +83
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt
  26. +33
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt
  27. +55
    -18
      src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
  28. +17
    -1
      src/main/java/com/ffii/fpsms/py/PyController.kt
  29. +7
    -0
      src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt
  30. +26
    -0
      src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt
  31. +21
    -0
      src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql
  32. +17
    -0
      src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql
  33. +181
    -0
      src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql
  34. +8
    -0
      src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql
  35. +220
    -0
      src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql
  36. +21
    -0
      src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql
  37. 二進制
      src/main/resources/onpack2030_exp/pp1181.image
  38. +38
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt
  39. +109
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt
  40. +40
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt
  41. +87
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt
  42. +206
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt
  43. +45
    -0
      src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt
  44. +94
    -0
      src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt
  45. +36
    -10
      src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt

+ 4
- 0
build.gradle 查看文件

@@ -93,6 +93,10 @@ kotlin {
jvmToolchain(17)
}

test {
useJUnitPlatform()
}

bootRun {
// Use db-local profile by default so datasource is loaded (application-db-local.yml).
// Override with: ./gradlew bootRun --args='--spring.profiles.active=prod'


+ 85
- 0
docs/deploy/20260819_po-m18id-remap.md 查看文件

@@ -0,0 +1,85 @@
# Deploy note — PO line m18Id rematch (recode)
Date: 2026-08-19
Branch / build: uncommitted (QA before prod)
Author: agent + QA

## Summary
- PO/DO sync failed when M18 recoded a product (`proId` changed, item **code** unchanged), e.g. `PFP002PO26080303` lines `20022` / `20023` / `20024`.
- Sync now **links** the new M18 id onto the existing local item (same **code + type**) and **does not** rebuild/delete `item_uom`.

## Scope
- Backend: `ItemsService.saveItem`, `M18MasterDataService.saveProduct` / `saveProducts`, remap helper
- Frontend: none
- DB / Liquibase: none
- Config / ops: none (use existing `GET /m18/test/po-by-code`)

## Commits
- (not committed yet)

## Safety rules (what this deploy will / will not do)

| Situation | Behaviour |
|-----------|-----------|
| Local item already has that `m18Id` | Unchanged: normal product/PO update |
| Same **code + type**, new unused `m18Id` | Update `items.m18Id` only. **No** UOM delete/rebuild, **no** QC/name wipe |
| New `m18Id` already on **another** item | Refuse (keep old mapping) |
| Same code, **different** type | Will **not** auto-link (same as before for type mismatch) |
| Brand-new M18 product, no local code | Creates item + UOMs as before |
| Scheduled product sync of already-mapped items | Unchanged full product/UOM sync |
| Local PO status not `PENDING` | PO resync is **skipped** (existing rule) |

## Pre-check SQL (prod or staging, before resync)

```sql
-- 1) Failed PO lines for this PO
SELECT d.id, d.m18Id AS m18_line_id, d.status, d.dataLog, d.createDate
FROM m18_data_log d
WHERE d.refType = 'Purchase Order Line'
AND d.status = 'FAIL'
AND d.dataLog LIKE '%PFP002PO26080303%'
ORDER BY d.id DESC
LIMIT 20;

-- 2) Local PO must be PENDING
SELECT id, code, status, m18Id
FROM purchase_order
WHERE code = 'PFP002PO26080303' AND deleted = 0;

-- 3) After you know the 3 M18 product codes, confirm one local row each
SELECT id, code, name, type, m18Id
FROM items
WHERE deleted = 0 AND code IN ('CODE1', 'CODE2', 'CODE3');
```

Confirm each of the three codes has **exactly one** local row, and its current `m18Id` is **not** already `20022`/`20023`/`20024` on a *different* item:

```sql
SELECT id, code, m18Id FROM items
WHERE deleted = 0 AND m18Id IN (20022, 20023, 20024);
```

Expected before fix: **no rows** (or only unrelated items — if any, **do not** auto-resync; fix mapping manually).

## Test plan

| # | Steps (who / where / data) | Expected result |
|---|----|-----|
| 1 | **Staging first.** Snapshot `items.m18Id` + `item_uom` counts for the 3 item codes. | Baseline recorded |
| 2 | Confirm M18 products `20022`/`20023`/`20024` still have the **same codes** as local items. | Codes match; types still map to the same FPSMS `type` |
| 3 | Confirm local PO `PFP002PO26080303` status is **PENDING**. | If not pending, stop — resync will skip the whole PO |
| 4 | Call `GET /m18/test/po-by-code?code=PFP002PO26080303` (auth as ops). | HTTP 200; `totalFail` for those 3 lines is 0 (or success list includes the PO) |
| 5 | SQL: `items.m18Id` for the 3 codes is now 20022/20023/20024. `item_uom` **row count and ids unchanged**. QC category / shelf life unchanged. | Remap-only; no UOM wipe |
| 6 | UI: PO workbench / PO detail for `PFP002PO26080303` shows **6 lines** (previously 3 missing). | Failed lines created |
| 7 | **Regression:** pick a normal mapped item, run scheduled or `GET /m18/product/{existingM18Id}`. | Item still updates as before; UOMs still sync |
| 8 | Confirm no new `PO_LINE FAIL` email for this PO after resync. | Alert not re-sent for these 3 proIds |

## Out of scope / not tested
- Recoded products whose **item code also changed** (will not auto-link)
- Non-`PENDING` PO overwrite (by design skipped)
- DO with the same recode (same `resolveLocalItemId` path; spot-check only if a DO uses these 3 items)
- Live M18 UOM id change on the recoded product (PO qty conversion still uses existing purchase UOM)

## Rollback
- Revert backend deploy (no Liquibase, no frontend).
- If only `items.m18Id` was updated, restore previous `m18Id` from the pre-check snapshot. PO lines already inserted stay; delete/fix only if QA rejects the PO.
- Do **not** run a full product resync on the new proIds expecting to undo UOMs — this path does not rewrite UOMs.

+ 3074
- 0
python/Bag4.py
文件差異過大導致無法顯示
查看文件


+ 38
- 0
python/Bag4.spec 查看文件

@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-


a = Analysis(
['Bag4.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)

exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Bag4',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

二進制
python/__pycache__/Bag4.cpython-314.pyc 查看文件


+ 17
- 11
python/installAndExe.txt 查看文件

@@ -1,24 +1,30 @@
# Bag3 Windows exe build (run all commands in this python/ folder)
# Bag3 / Bag4 Windows exe build (run all commands in this python/ folder)

py -m pip install --upgrade pyinstaller
py -m pip install --upgrade pywin32
py -m pip install --upgrade Pillow "qrcode[pil]" requests
py -m pip install --upgrade Pillow "qrcode[pil]" requests pyserial

py -m PyInstaller --noconfirm --clean Bag3.spec
# --- Bag4 (DataFlex with expiry) ---
py -m PyInstaller --noconfirm --clean Bag4.spec
# Output: dist\Bag4.exe (one-file). Copy Bag4.exe to the client PC.
# Settings are created next to the exe: bag4_settings.json
# Do not overwrite Bag3.exe; Bag3 and Bag4 are separate.

# Output: dist\Bag3\Bag3.exe plus dist\Bag3\_internal\...
# Copy the ENTIRE dist\Bag3\ folder to the client PC (not only Bag3.exe).
# --- Bag3 (DataFlex without expiry) ---
py -m PyInstaller --noconfirm --clean Bag3.spec
# Output: dist\Bag3.exe

# --- If the client exe flashes and closes ---

1) On the client PC, open cmd in the Bag3 folder and run:
Bag3.exe
You should see the error in the console, or open bag3_startup_error.log next to Bag3.exe.
1) On the client PC, open cmd in the folder that contains the exe and run:
Bag4.exe
You should see the error in the console, or open bag4_startup_error.log next to Bag4.exe.
(Bag3: bag3_startup_error.log)

2) Compare BUILD machines (both should match):
py --version
py -m PyInstaller --version
py -m pip show pyinstaller pywin32 Pillow qrcode requests
py -m pip show pyinstaller pywin32 Pillow qrcode requests pyserial

A broken build is often caused by:
- Different Python major version (e.g. 3.13 vs 3.11)
@@ -28,8 +34,8 @@ py -m PyInstaller --noconfirm --clean Bag3.spec
3) Rebuild on the machine that works, or reinstall Python from python.org (64-bit)
and reinstall deps above, then rebuild.

4) Bag3.spec disables UPX (upx=False) for stability; do not re-enable unless you test on the client.
4) Spec files disable UPX (upx=False) for stability; do not re-enable unless you test on the client.

5) Client needs 64-bit Windows and Microsoft VC++ Redistributable (same as your Python installer).

6) Antivirus may quarantine files under _internal\ — whitelist the Bag3 folder if the log mentions missing DLL.
6) Antivirus may quarantine the exe — whitelist the folder if the log mentions missing DLL.

+ 186
- 0
scripts/generate_item_default_shelf_life_liquibase.py 查看文件

@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Generate Liquibase seed SQL from 工埸產品保質期 Excel (joExpiry.xlsx).

Stores both chilled defaultDays and minus18Days.
useMinus18 = 1 only when the row has no non-18 shelf life (frozen-only).
Flip useMinus18 in DB later for items that should print -18 days.

Example:
py scripts/generate_item_default_shelf_life_liquibase.py
py scripts/generate_item_default_shelf_life_liquibase.py "C:\\Users\\Administrator\\Downloads\\joExpiry.xlsx"
"""
from __future__ import annotations

import argparse
import datetime as dt
from pathlib import Path

import openpyxl

PREFERRED_SHEET = "XXXXXXXX"
OUT_SQL = Path(
"src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/"
"05_seed_item_default_shelf_life_minus18_flag.sql"
)


def sql_str(value: str | None) -> str:
if value is None:
return "NULL"
return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'"


def sql_int(value: int | None) -> str:
return "NULL" if value is None else str(value)


def norm_temp(raw) -> str | None:
if raw is None:
return None
s = str(raw).strip().replace("℃", "").replace("°C", "").replace(" ", "")
if not s or s.lower() in ("n/a", "none", "null", "-", "nan"):
return None
return s


def is_minus18(temp: str | None) -> bool:
return temp in ("-18", "-18.0")


def to_days(raw) -> int | None:
if raw is None or raw == "":
return None
if isinstance(raw, str) and raw.strip().lower() in ("n/a", "-", "none", "null"):
return None
try:
n = int(round(float(raw)))
except (TypeError, ValueError):
return None
return n if n > 0 else None


def pick_days(t1, d1, t2, d2) -> tuple[int | None, str | None, int | None]:
"""Return (chilledDays, chilledTemp, minus18Days)."""
chilled_days = None
chilled_temp = None
minus18_days = None
for temp, days in ((norm_temp(t1), to_days(d1)), (norm_temp(t2), to_days(d2))):
if not temp or not days:
continue
if is_minus18(temp):
if minus18_days is None:
minus18_days = days
elif chilled_days is None:
chilled_days = days
chilled_temp = temp
return chilled_days, chilled_temp, minus18_days


def load_rows(xlsx: Path) -> tuple[str, list[dict]]:
wb = openpyxl.load_workbook(xlsx, data_only=True)
sheet_name = PREFERRED_SHEET if PREFERRED_SHEET in wb.sheetnames else wb.sheetnames[0]
ws = wb[sheet_name]
kept: list[dict] = []
seen: set[str] = set()
for row in ws.iter_rows(min_row=4, values_only=True):
code_raw = row[3]
if not code_raw:
continue
code = str(code_raw).strip().upper()
if not code or code in seen:
continue
chilled, storage, minus18 = pick_days(row[5], row[6], row[7], row[8])
if chilled is None and minus18 is None:
continue
seen.add(code)
use_minus18 = 1 if chilled is None else 0
name = str(row[4]).strip() if row[4] else ""
cat = str(row[2]).strip() if row[2] else ""
remark = f"{cat} {name}".strip()[:255]
kept.append(
{
"itemCode": code,
"defaultDays": chilled,
"minus18Days": minus18,
"useMinus18": use_minus18,
"storageC": "-18" if use_minus18 else storage,
"remarks": remark,
}
)
return sheet_name, kept


def render_sql(sheet_name: str, kept: list[dict], source_name: str) -> str:
today = dt.date.today().isoformat()
n_flag = sum(1 for r in kept if r["useMinus18"] == 1)
lines = [
"--liquibase formatted sql",
"",
"--changeset fpsms:seed_item_default_shelf_life_minus18_flag",
f"--comment: Upsert joExpiry.xlsx sheet {sheet_name}: defaultDays + minus18Days + useMinus18 flag",
f"-- generated {today}; rows={len(kept)} useMinus18=1 (frozen-only default)={n_flag}",
"INSERT INTO `item_default_shelf_life`",
"(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`,",
" `itemCode`, `defaultDays`, `minus18Days`, `useMinus18`, `openedDays`, `storageC`, `remarks`)",
"VALUES",
]
value_rows = []
for r in kept:
value_rows.append(
"("
+ ", ".join(
[
"NOW()",
"'system'",
"0",
"NOW()",
"'system'",
"0",
sql_str(r["itemCode"]),
sql_int(r["defaultDays"]),
sql_int(r["minus18Days"]),
str(r["useMinus18"]),
"NULL",
sql_str(r["storageC"]),
sql_str(r["remarks"]),
]
)
+ ")"
)
lines.append(",\n".join(value_rows))
lines.append("ON DUPLICATE KEY UPDATE")
lines.append(" `defaultDays` = VALUES(`defaultDays`),")
lines.append(" `minus18Days` = VALUES(`minus18Days`),")
lines.append(" `useMinus18` = VALUES(`useMinus18`),")
lines.append(" `openedDays` = VALUES(`openedDays`),")
lines.append(" `storageC` = VALUES(`storageC`),")
lines.append(" `remarks` = VALUES(`remarks`),")
lines.append(" `modified` = NOW(),")
lines.append(" `modifiedBy` = 'system',")
lines.append(" `deleted` = 0;")
lines.append("")
return "\n".join(lines)


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"xlsx",
nargs="?",
default=str(Path.home() / "Downloads" / "joExpiry.xlsx"),
)
args = parser.parse_args()
xlsx = Path(args.xlsx)
if not xlsx.is_file():
raise SystemExit(f"Excel not found: {xlsx}")
sheet_name, kept = load_rows(xlsx)
sql = render_sql(sheet_name, kept, xlsx.name)
OUT_SQL.parent.mkdir(parents=True, exist_ok=True)
OUT_SQL.write_text(sql, encoding="utf-8")
n_flag = sum(1 for r in kept if r["useMinus18"] == 1)
print(f"sheet={sheet_name} rows={len(kept)} useMinus18=1={n_flag} useMinus18=0={len(kept) - n_flag}")
print(f"wrote {OUT_SQL}")


if __name__ == "__main__":
main()

+ 28
- 16
src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt 查看文件

@@ -195,6 +195,17 @@ open class M18MasterDataService(
return itemsService.findByM18Id(m18ItemId)?.id
}

private fun mapM18ProductType(udfProducttype: String?): String {
return when (udfProducttype) {
M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type
M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type
M18ItemType.FG.type -> ItemType.FG.type
M18ItemType.SFG.type -> ItemType.SFG.type
M18ItemType.ITEM.type -> ItemType.ITEM.type
else -> ItemType.MATERIAL.type
}
}

open fun saveProduct(id: Long): MessageResponse? {
try {
ensureCunitSeededForAllIfEmpty()
@@ -203,20 +214,14 @@ open class M18MasterDataService(
val price = itemDetail?.data?.price

if (itemDetail != null && pro != null) {
val mappedType = mapM18ProductType(pro.udfProducttype)
val existingItem = itemsService.findByM18Id(id)
val saveItemRequest = NewItemRequest(
code = pro.code,
name = pro.desc,
// type = if (pro.seriesId == m18Config.SERIESID_PF) ProductType.MATERIAL
// else ItemType.PRODUCT,
type = when (pro.udfProducttype) {
M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type
M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type
M18ItemType.FG.type -> ItemType.FG.type
M18ItemType.SFG.type -> ItemType.SFG.type
M18ItemType.ITEM.type -> ItemType.ITEM.type
else -> ItemType.MATERIAL.type
},
type = mappedType,
id = existingItem?.id,
description = pro.desc,
remarks = null,
@@ -247,6 +252,12 @@ open class M18MasterDataService(
logger.error("saveItem duplicate code for M18 item $id (code=${pro.code}): ${savedItem.message}")
return null
}
if (ItemM18IdRemapSupport.isM18IdLinkOnly(existingItem == null, savedItem.message)) {
logger.warn(
"Linked M18 product id=$id code=${pro.code} to existing local item id=$localItemId; skip UOM rebuild"
)
return savedItem.copy(id = localItemId)
}
logger.info("Processing item uom...")
// Find the item uom that ready to delete (not in m18)
val existingItemUoms = itemUomService.findAllByItemsId(localItemId)
@@ -382,6 +393,7 @@ open class M18MasterDataService(
val price = itemDetail?.data?.price

if (itemDetail != null && pro != null) {
val mappedType = mapM18ProductType(pro.udfProducttype)
// ── Use cache instead of direct call ────────────────────────
val existingItem = itemCache.getOrPut(item.id) {
itemsService.findByM18Id(item.id)
@@ -390,14 +402,7 @@ open class M18MasterDataService(
val saveItemRequest = NewItemRequest(
code = pro.code,
name = pro.desc,
type = when (pro.udfProducttype) {
M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type
M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type
M18ItemType.FG.type -> ItemType.FG.type
M18ItemType.SFG.type -> ItemType.SFG.type
M18ItemType.ITEM.type -> ItemType.ITEM.type
else -> ItemType.MATERIAL.type
},
type = mappedType,
id = existingItem?.id,
description = pro.desc,
remarks = null,
@@ -430,6 +435,13 @@ open class M18MasterDataService(
logger.error("saveItem duplicate code for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}")
return@forEach
}
if (ItemM18IdRemapSupport.isM18IdLinkOnly(existingItem == null, savedItem.message)) {
logger.warn(
"Linked M18 product id=${item.id} code=${pro.code} to existing local item id=$localItemId; skip UOM rebuild"
)
successList.add(item.id)
return@forEach
}
logger.info("Processing item uom...")

val existingItemUoms = itemUomService.findAllByItemsId(localItemId)


+ 41
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt 查看文件

@@ -0,0 +1,41 @@
package com.ffii.fpsms.modules.jobOrder.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Lob
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.type.SqlTypes

@Entity
@Table(name = "onpack_template_file")
open class OnPackTemplateFile : BaseEntity<Long>() {

@NotNull
@Size(max = 20)
@Column(name = "machine", length = 20, nullable = false)
open var machine: String? = null

@NotNull
@Size(max = 50)
@Column(name = "itemCode", length = 50, nullable = false)
open var itemCode: String? = null

@NotNull
@Size(max = 200)
@Column(name = "fileName", length = 200, nullable = false)
open var fileName: String? = null

@NotNull
@Column(name = "byteSize", nullable = false)
open var byteSize: Int? = null

@NotNull
@Lob
@JdbcTypeCode(SqlTypes.BLOB)
@Column(name = "fileBytes", nullable = false, columnDefinition = "MEDIUMBLOB")
open var fileBytes: ByteArray? = null
}

+ 29
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt 查看文件

@@ -0,0 +1,29 @@
package com.ffii.fpsms.modules.jobOrder.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.stereotype.Repository

@Repository
interface OnPackTemplateFileRepository : AbstractRepository<OnPackTemplateFile, Long> {

fun findByDeletedFalseAndMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase(
machine: String,
itemCode: String,
fileName: String,
): OnPackTemplateFile?

fun findFirstByDeletedFalseAndMachineAndFileNameIgnoreCase(
machine: String,
fileName: String,
): OnPackTemplateFile?

fun findByMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase(
machine: String,
itemCode: String,
fileName: String,
): OnPackTemplateFile?

fun findByDeletedFalseAndMachineOrderByItemCodeAscFileNameAsc(machine: String): List<OnPackTemplateFile>

fun findByDeletedFalseOrderByMachineAscItemCodeAscFileNameAsc(): List<OnPackTemplateFile>
}

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt 查看文件

@@ -83,6 +83,7 @@ class LaserBag2AutoSendService(
jobOrderId = jo.id,
jobOrderNo = jo.code,
lotNo = jo.lotNo,
expiryDate = jo.expiryDate?.toString(),
source = "AUTO",
),
)


+ 45
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt 查看文件

@@ -0,0 +1,45 @@
package com.ffii.fpsms.modules.jobOrder.service

import java.nio.charset.StandardCharsets

/**
* OnPack `.image` templates are often UTF-16 LE with BOM (Windows export).
* Decode/encode must round-trip the same encoding or SmartDate X40 will refuse the job.
*/
object OnPackImageTemplateCodec {

private val bomUtf16Le = byteArrayOf(0xFF.toByte(), 0xFE.toByte())
private val bomUtf16Be = byteArrayOf(0xFE.toByte(), 0xFF.toByte())
private val bomUtf8 = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())

fun decode(bytes: ByteArray): Pair<String, (String) -> ByteArray> {
return when {
bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] -> {
val body = bytes.copyOfRange(2, bytes.size)
val text = String(body, StandardCharsets.UTF_16LE)
text to { s -> bomUtf16Le + s.toByteArray(StandardCharsets.UTF_16LE) }
}
bytes.size >= 2 && bytes[0] == bomUtf16Be[0] && bytes[1] == bomUtf16Be[1] -> {
val body = bytes.copyOfRange(2, bytes.size)
val text = String(body, StandardCharsets.UTF_16BE)
text to { s -> bomUtf16Be + s.toByteArray(StandardCharsets.UTF_16BE) }
}
bytes.size >= 3 && bytes[0] == bomUtf8[0] && bytes[1] == bomUtf8[1] && bytes[2] == bomUtf8[2] -> {
val body = bytes.copyOfRange(3, bytes.size)
val text = String(body, StandardCharsets.UTF_8)
text to { s -> bomUtf8 + s.toByteArray(StandardCharsets.UTF_8) }
}
bytes.size >= 2 && bytes[0] == 0x3C.toByte() && bytes[1] == 0x00.toByte() -> {
val text = String(bytes, StandardCharsets.UTF_16LE)
text to { s -> s.toByteArray(StandardCharsets.UTF_16LE) }
}
else -> {
val utf8 = String(bytes, StandardCharsets.UTF_8)
utf8 to { s -> s.toByteArray(StandardCharsets.UTF_8) }
}
}
}

fun hasUtf16LeBom(bytes: ByteArray): Boolean =
bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1]
}

+ 152
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt 查看文件

@@ -0,0 +1,152 @@
package com.ffii.fpsms.modules.jobOrder.service

/**
* Puts today's expiry BMP into 汁水機 OnPack `.image` XML without replacing LOGO_3
* (production / print date).
*
* If the designer template already has an expiry slot (`LOGO_5` or `LOGO_EXP`), that
* field's FileName is rewritten and geometry is left as in the XML (no second field).
* Old 4-logo templates still get a generated `LOGO_EXP` (QR moved down).
*/
object OnPackJuiceExpiryXml {

private val hasLogo4 = Regex("""<Name>\s*LOGO_4\s*</Name>""")
private val logo3X = Regex(
"""(?s)<Name>\s*LOGO_3\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<X>\s*(\d+)\s*</X>""",
)
private val logo3Y = Regex(
"""(?s)<Name>\s*LOGO_3\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<Y>\s*(\d+)\s*</Y>""",
)
private val logo3HeightReplace = Regex(
"""(?s)(<Name>\s*LOGO_3\s*</Name>[\s\S]*?<Height>)\s*\d+\s*(</Height>)""",
)
private val logo4Height = Regex(
"""(?s)<Name>\s*LOGO_4\s*</Name>[\s\S]*?<Height>\s*(\d+)\s*</Height>""",
)
private val logo4YReplace = Regex(
"""(?s)(<Name>\s*LOGO_4\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<Y>)\s*\d+\s*(</Y>)""",
)
private val logo4HeightReplace = Regex(
"""(?s)(<Name>\s*LOGO_4\s*</Name>[\s\S]*?<Height>)\s*\d+\s*(</Height>)""",
)
private val canvasSize = Regex(
"""(?s)<Width>\s*(\d+)\s*</Width>\s*<Height>\s*(\d+)\s*</Height>\s*<FieldList""",
)
private val logoExpBlock = Regex(
"""(?s)<Logo[^>]*>\s*<Name>\s*LOGO_EXP\s*</Name>[\s\S]*?</Logo>""",
)
private val logo4Open = Regex(
"""(?s)(<Logo[^>]*>\s*<Name>\s*LOGO_4\s*</Name>)""",
)

fun applyExpiry(xml: String, bmpFileName: String, bmpPixelWidth: Int): String {
val name = OnPackXml.escapeText(bmpFileName.trim())
if (name.isEmpty() || !xml.contains("</FieldList>")) {
return xml
}
existingExpirySlotName(xml)?.let { slot ->
return rewriteLogoFileName(xml, slot, name)
}
if (!hasLogo4.containsMatchIn(xml)) {
return xml
}
val layout = layoutFor(xml, bmpPixelWidth)
var out = logo3HeightReplace.replaceFirst(xml, "$1${layout.dateHeight}$2")
out = logo4YReplace.replaceFirst(out, "$1${layout.qrY}$2")
out = logo4HeightReplace.replaceFirst(out, "$1${layout.qrHeight}$2")
val field = expiryLogoXml(
layout.x,
layout.expY,
name,
layout.expWidth,
layout.expHeight,
OnPackXml.nextId(xml),
)
return if (logoExpBlock.containsMatchIn(out)) {
logoExpBlock.replace(out, field)
} else {
insertLogoExp(out, field)
}
}

/** Designer expiry slot: prefer LOGO_EXP, then LOGO_5 (CoLOS export). */
fun existingExpirySlotName(xml: String): String? {
if (Regex("""<Name>\s*LOGO_EXP\s*</Name>""").containsMatchIn(xml)) return "LOGO_EXP"
if (Regex("""<Name>\s*LOGO_5\s*</Name>""").containsMatchIn(xml)) return "LOGO_5"
return null
}

fun rewriteLogoFileName(xml: String, slot: String, fileName: String): String {
val re = Regex(
"""(?s)(<Name>\s*${Regex.escape(slot)}\s*</Name>[\s\S]*?<FileName>)([^<]*)(</FileName>)""",
)
return re.replaceFirst(xml, "$1$fileName$3")
}

internal fun layoutFor(xml: String, bmpPixelWidth: Int): JuiceExpiryLayout {
val x = logo3X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 500
val logo3YVal = logo3Y.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2500
val qrH0 = logo4Height.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 3000
val canvas = canvasSize.find(xml)
val canvasW = canvas?.groupValues?.get(1)?.toIntOrNull() ?: 5300
val canvasH = canvas?.groupValues?.get(2)?.toIntOrNull() ?: 7100
val dateHeight = DATE_HEIGHT_UNITS
val expHeight = EXP_HEIGHT_UNITS
val expY = logo3YVal + dateHeight + GAP_AFTER_DATE
val qrBottomMax = (canvasH - 80).coerceAtLeast(expY + expHeight + 40)
var qrY = expY + expHeight + GAP_AFTER_EXP
if (qrY >= qrBottomMax) {
qrY = (expY + expHeight + 80).coerceAtMost(qrBottomMax - 1)
}
val qrHeight = qrH0.coerceAtMost((qrBottomMax - qrY).coerceAtLeast(1))
val expWidth = (bmpPixelWidth * expHeight / BMP_TARGET_HEIGHT).coerceIn(1800, 4000)
.coerceAtMost((canvasW - x).coerceAtLeast(200))
return JuiceExpiryLayout(
x = x,
dateHeight = dateHeight,
expY = expY,
expWidth = expWidth,
expHeight = expHeight,
qrY = qrY,
qrHeight = qrHeight,
canvasWidth = canvasW,
canvasHeight = canvasH,
)
}

private fun insertLogoExp(xml: String, field: String): String {
if (logo4Open.containsMatchIn(xml)) {
return logo4Open.replaceFirst(xml, "$field$1")
}
return xml.replaceFirst("</FieldList>", "$field</FieldList>")
}

private fun expiryLogoXml(
x: Int,
y: Int,
bmpFileName: String,
width: Int,
height: Int,
id: Int,
): String {
return """<Logo type='Logo.V1'><Name>LOGO_EXP</Name><ID>$id</ID><Geometry><X>$x</X><Y>$y</Y><Rotation>0</Rotation></Geometry><FieldColor>BLACK</FieldColor><FileName>$bmpFileName</FileName><Width>$width</Width><Height>$height</Height></Logo>"""
}

private const val DATE_HEIGHT_UNITS = 640
private const val EXP_HEIGHT_UNITS = 260
private const val GAP_AFTER_DATE = 80
private const val GAP_AFTER_EXP = 800
private const val BMP_TARGET_HEIGHT = 180
}

internal data class JuiceExpiryLayout(
val x: Int,
val dateHeight: Int,
val expY: Int,
val expWidth: Int,
val expHeight: Int,
val qrY: Int,
val qrHeight: Int,
val canvasWidth: Int = 5300,
val canvasHeight: Int = 7100,
)

+ 61
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt 查看文件

@@ -0,0 +1,61 @@
package com.ffii.fpsms.modules.jobOrder.service

/**
* Injects an expiry text field into OnPack2023 lemon `.image` XML without replacing
* TEXT_3 (production / print date, TimeDate offset 0).
*
* Lot-like TEXT_2 is at a higher X; TEXT_3 is lower X (90° rotation). Expiry sits between them.
*/
object OnPackLemonExpiryXml {

private val text2X = Regex(
"""(?s)<Name>\s*TEXT_2\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<X>\s*(\d+)\s*</X>""",
)
private val text3X = Regex(
"""(?s)<Name>\s*TEXT_3\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<X>\s*(\d+)\s*</X>""",
)
private val text2Y = Regex(
"""(?s)<Name>\s*TEXT_2\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<Y>\s*(\d+)\s*</Y>""",
)
private val text2PointSize = Regex(
"""(?s)(<Name>\s*TEXT_2\s*</Name>[\s\S]*?<PointSizeInHM>)(\d+)(</PointSizeInHM>)""",
)
private val textExpStatic = Regex(
"""(?s)(<Name>\s*TEXT_EXP\s*</Name>[\s\S]*?<Static\s+type=(?:'|\u0022)StaticSrc\.V1(?:'|\u0022)[^>]*>\s*<Text>)([^<]*)(</Text>)""",
)

private val hasText3 = Regex("""<Name>\s*TEXT_3\s*</Name>""")

fun applyExpiry(xml: String, expiryText: String): String {
val compact = OnPackXml.escapeText(expiryText.trim())
if (compact.isEmpty() || !hasText3.containsMatchIn(xml) || !xml.contains("</FieldList>")) {
return xml
}
val smallerLot = shrinkText2(xml)
if (textExpStatic.containsMatchIn(smallerLot)) {
return textExpStatic.replace(smallerLot) { m ->
m.groupValues[1] + compact + m.groupValues[3]
}
}
return insertTextExp(smallerLot, compact)
}

internal fun shrinkText2(xml: String): String {
return text2PointSize.replaceFirst(xml, "$1${TEXT_2_POINT_SIZE}$3")
}

private fun insertTextExp(xml: String, expiryYyyymmdd: String): String {
val lotX = text2X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2900
val dateX = text3X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2000
val y = text2Y.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2250
val expX = (lotX + dateX) / 2
val field = expiryFieldXml(expX, y, expiryYyyymmdd, OnPackXml.nextId(xml))
return xml.replaceFirst("</FieldList>", "$field</FieldList>")
}

private fun expiryFieldXml(x: Int, y: Int, expiryText: String, id: Int): String {
return """<AlphaNumeric type='AlphaNum.V1'><Name>TEXT_EXP</Name><ID>$id</ID><Geometry><X>$x</X><Y>$y</Y><Rotation>90</Rotation></Geometry><FieldColor>BLACK</FieldColor><Logged>false</Logged><Text type='TextBased.V1'><Inverted>false</Inverted><OverlayMode>MERGE</OverlayMode><Font type='Font.V1'><Typeface>Arial</Typeface><PointSizeInHM>560</PointSizeInHM><Filename>arialbd.ttf</Filename><WidthScaleFactor>93</WidthScaleFactor></Font></Text><Static type='StaticSrc.V1'><Text>$expiryText</Text></Static></AlphaNumeric>"""
}

private const val TEXT_2_POINT_SIZE = "520"
}

+ 364
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt 查看文件

@@ -0,0 +1,364 @@
package com.ffii.fpsms.modules.jobOrder.service

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFile
import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFileRepository
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedCatalogDto
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedItemDto
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse
import org.slf4j.LoggerFactory
import org.springframework.core.io.support.PathMatchingResourcePatternResolver
import org.springframework.dao.DataAccessException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.sql.Timestamp
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

@Service
open class OnPackTemplateFileService(
private val repository: OnPackTemplateFileRepository,
private val jdbcDao: JdbcDao,
) {
private val logger = LoggerFactory.getLogger(javaClass)
private val resourceResolver = PathMatchingResourcePatternResolver()

@Transactional(readOnly = true)
open fun list(machine: String?): List<OnPackTemplateFileDto> {
return templateTableOr(emptyList()) {
val normalized = machine?.takeIf { it.isNotBlank() }?.let { normalizeMachine(it) }
val sql = buildString {
append(
"""
SELECT id, machine, itemCode, fileName, byteSize, modified
FROM onpack_template_file
WHERE deleted = 0
""".trimIndent(),
)
if (normalized != null) append(" AND machine = :machine")
append(" ORDER BY machine, itemCode, fileName")
}
val params = if (normalized == null) emptyMap() else mapOf("machine" to normalized)
jdbcDao.queryForList(sql, params).map { row ->
OnPackTemplateFileDto(
id = (row["id"] as Number).toLong(),
machine = row["machine"]?.toString().orEmpty(),
itemCode = row["itemCode"]?.toString().orEmpty(),
fileName = row["fileName"]?.toString().orEmpty(),
byteSize = (row["byteSize"] as? Number)?.toInt() ?: 0,
modified = formatModified(row["modified"]),
)
}
}
}

@Transactional
open fun upload(machineRaw: String, itemCodeRaw: String, files: List<MultipartFile>): OnPackTemplateUploadResponse {
val machine = normalizeMachine(machineRaw)
val itemCode = normalizeItemCode(itemCodeRaw)
require(files.isNotEmpty()) { "請選擇要上傳的檔案(.image / .bmp / .job)" }
val saved = mutableListOf<String>()
files.forEach { part ->
if (part.isEmpty) return@forEach
val original = part.originalFilename ?: part.name
val storedName = storedFileName(machine, itemCode, original)
val bytes = part.bytes
require(bytes.isNotEmpty()) { "空檔案:$original" }
require(bytes.size <= MAX_BYTES) { "檔案過大(上限 ${MAX_BYTES / (1024 * 1024)}MB):$original" }
val row = try {
repository.findByMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase(
machine,
itemCode,
storedName,
) ?: OnPackTemplateFile().apply {
this.machine = machine
this.itemCode = itemCode
this.fileName = storedName
}
} catch (e: DataAccessException) {
throw IllegalArgumentException(
"無法儲存 OnPack 模板。請重啟後端以執行 Liquibase(建立 onpack_template_file)。",
e,
)
}
row.deleted = false
row.fileBytes = bytes
row.byteSize = bytes.size
row.fileName = storedName
row.itemCode = itemCode
row.machine = machine
try {
repository.save(row)
} catch (e: DataAccessException) {
throw IllegalArgumentException(
"無法儲存 OnPack 模板。請重啟後端以執行 Liquibase(建立 onpack_template_file)。",
e,
)
}
saved.add(storedName)
}
require(saved.isNotEmpty()) { "沒有可儲存的檔案" }
try {
ensureOnPackQrRow(itemCode, machine, saved)
} catch (e: DataAccessException) {
logger.warn("onpack_qr insert after template upload failed for {}", itemCode, e)
}
return OnPackTemplateUploadResponse(machine = machine, itemCode = itemCode, saved = saved)
}

@Transactional
open fun softDelete(id: Long) {
val row = try {
repository.findById(id).orElseThrow { IllegalArgumentException("找不到該模板檔") }
} catch (e: DataAccessException) {
throw IllegalArgumentException(
"無法刪除 OnPack 模板。請重啟後端以執行 Liquibase(建立 onpack_template_file)。",
e,
)
}
row.deleted = true
repository.save(row)
}

@Transactional(readOnly = true)
open fun loadImage(machine: String, itemCode: String): ByteArray? {
val code = itemCode.trim()
if (code.isEmpty()) return null
return templateTableOr(null) {
imageFileNameCandidates(machine, code).firstNotNullOfOrNull { name ->
repository.findByDeletedFalseAndMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase(
machine,
code,
name,
)?.fileBytes
}
}
}

@Transactional(readOnly = true)
open fun loadAsset(machine: String, fileName: String): ByteArray? {
val safe = sanitizeFileName(fileName) ?: return null
return templateTableOr(null) {
repository.findFirstByDeletedFalseAndMachineAndFileNameIgnoreCase(machine, safe)?.fileBytes
}
}

@Transactional(readOnly = true)
open fun itemCodesWithImage(machine: String): Set<String> {
return templateTableOr(emptySet()) {
jdbcDao.queryForStrings(
"""
SELECT DISTINCT UPPER(TRIM(itemCode))
FROM onpack_template_file
WHERE deleted = 0
AND machine = :machine
AND LOWER(fileName) LIKE '%.image'
""".trimIndent(),
mapOf("machine" to machine),
).mapNotNull { it.trim().uppercase().takeIf { code -> code.isNotEmpty() } }.toSet()
}
}

@Transactional(readOnly = true)
open fun supportedCatalog(): OnPackSupportedCatalogDto {
return OnPackSupportedCatalogDto(
juice = supportedItems(MACHINE_JUICE),
lemon = supportedItems(MACHINE_LEMON),
)
}

private fun supportedItems(machine: String): List<OnPackSupportedItemDto> {
val registered = registeredCodes(machine)
val inDatabase = itemCodesWithImage(machine)
val builtin = builtinImageCodes(machine)
return mergeSupported(registered, inDatabase, builtin)
}

private fun registeredCodes(machine: String): Set<String> {
return try {
val templateType = if (machine == MACHINE_LEMON) "text" else "bmp"
val sql = if (machine == MACHINE_LEMON) {
"""
SELECT DISTINCT UPPER(TRIM(code)) AS code
FROM onpack_qr
WHERE LOWER(TRIM(template_type)) = :type
AND TRIM(code) <> ''
""".trimIndent()
} else {
"""
SELECT DISTINCT UPPER(TRIM(code)) AS code
FROM onpack_qr
WHERE COALESCE(NULLIF(TRIM(template_type), ''), 'bmp') = :type
AND TRIM(code) <> ''
""".trimIndent()
}
jdbcDao.queryForList(sql, mapOf("type" to templateType))
.mapNotNull { it["code"]?.toString()?.trim()?.uppercase()?.takeIf { code -> code.isNotEmpty() } }
.toSet()
} catch (e: DataAccessException) {
logger.warn("onpack_qr lookup failed for machine={}", machine, e)
emptySet()
}
}

private fun builtinImageCodes(machine: String): Set<String> {
return try {
scanBuiltinImageCodes(machine, resourceResolver)
} catch (e: Exception) {
logger.warn("Failed to scan classpath OnPack templates for machine={}", machine, e)
emptySet()
}
}

private fun <T> templateTableOr(fallback: T, block: () -> T): T {
return try {
block()
} catch (e: DataAccessException) {
logger.warn(
"onpack_template_file is unavailable; using classpath templates. Restart the backend so Liquibase can create the table.",
e,
)
fallback
}
}

private fun formatModified(value: Any?): String? = when (value) {
null -> null
is LocalDateTime -> value.format(ISO_TS)
is Timestamp -> value.toLocalDateTime().format(ISO_TS)
else -> value.toString().takeIf { it.isNotBlank() }
}

private fun ensureOnPackQrRow(itemCode: String, machine: String, savedNames: List<String>) {
val templateType = if (machine == MACHINE_LEMON) "text" else "bmp"
val filename = savedNames.firstOrNull { it.endsWith(".image", ignoreCase = true) }
?: savedNames.first()
val existing = jdbcDao.queryForList(
"""
SELECT code FROM onpack_qr
WHERE UPPER(TRIM(code)) = :code
AND LOWER(TRIM(COALESCE(NULLIF(TRIM(template_type), ''), 'bmp'))) = :type
LIMIT 1
""".trimIndent(),
mapOf("code" to itemCode, "type" to templateType),
)
if (existing.isNotEmpty()) return
jdbcDao.executeUpdate(
"""
INSERT INTO onpack_qr (code, filename, template_type)
VALUES (:code, :filename, :type)
""".trimIndent(),
mapOf(
"code" to itemCode,
"filename" to filename,
"type" to templateType,
),
)
}

companion object {
const val MACHINE_JUICE = "juice"
const val MACHINE_LEMON = "lemon"
private const val MAX_BYTES = 8 * 1024 * 1024
private val ISO_TS: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME

fun scanBuiltinImageCodes(
machine: String,
resolver: PathMatchingResourcePatternResolver = PathMatchingResourcePatternResolver(),
): Set<String> {
val pattern = if (machine == MACHINE_LEMON) {
"classpath*:onpack2030_2/*.image"
} else {
"classpath*:onpack2030/*.image"
}
return resolver.getResources(pattern).mapNotNull { resource ->
itemCodeFromImageFileName(resource.filename ?: return@mapNotNull null)
}.filter { isListedBuiltinCode(it) }.toSet()
}
private val ITEM_CODE = Regex("""^[A-Za-z0-9][A-Za-z0-9._-]{0,48}$""")
private val LISTED_BUILTIN_CODE = Regex("""^PP\d+$""")
private val ALLOWED_EXT = setOf("image", "bmp", "job")

fun normalizeMachine(raw: String): String {
val v = raw.trim().lowercase()
return when (v) {
MACHINE_JUICE, "bmp", "onpack2030", "汁水機" -> MACHINE_JUICE
MACHINE_LEMON, "text", "onpack2030_2", "檸檬機" -> MACHINE_LEMON
else -> throw IllegalArgumentException("machine 必須是 juice(汁水機)或 lemon(檸檬機)")
}
}

fun normalizeItemCode(raw: String): String {
val code = raw.trim().uppercase()
require(ITEM_CODE.matches(code)) { "品號格式不正確:$raw" }
return code
}

fun sanitizeFileName(raw: String): String? {
val base = raw.replace('\\', '/').substringAfterLast('/').trim()
if (base.isEmpty() || base == "." || base == "..") return null
if (base.any { it.code < 32 || it == ':' }) return null
val ext = base.substringAfterLast('.', "").lowercase()
if (ext !in ALLOWED_EXT) return null
return base
}

fun storedFileName(machine: String, itemCode: String, originalName: String): String {
val safe = sanitizeFileName(originalName)
?: throw IllegalArgumentException("不支援的檔名(只接受 .image / .bmp / .job):$originalName")
return if (safe.endsWith(".image", ignoreCase = true)) {
if (machine == MACHINE_LEMON) "${itemCode.uppercase()}.image"
else "${itemCode.lowercase()}.image"
} else {
safe
}
}

fun imageFileNameCandidates(machine: String, itemCode: String): List<String> {
val lower = itemCode.trim().lowercase()
val upper = itemCode.trim().uppercase()
return if (machine == MACHINE_LEMON) {
listOf("$upper.image", "$lower.image")
} else {
listOf("$lower.image", "$upper.image")
}
}

fun itemCodeFromImageFileName(fileName: String): String? {
val base = fileName.replace('\\', '/').substringAfterLast('/').substringBeforeLast('.')
val code = base.trim().uppercase()
if (code.isEmpty() || code == "DEFAULT") return null
if (!ITEM_CODE.matches(code)) return null
return code
}

/** Classpath test/dev templates (TEST*, TT_*, LO*, LPP*) stay loadable for ZIP but are not listed as supported items. */
fun isListedBuiltinCode(code: String): Boolean {
return LISTED_BUILTIN_CODE.matches(code.trim().uppercase())
}

fun mergeSupported(
registered: Set<String>,
inDatabase: Set<String>,
builtin: Set<String>,
): List<OnPackSupportedItemDto> {
return (registered + inDatabase + builtin)
.map { it.trim().uppercase() }
.filter { it.isNotEmpty() }
.toSortedSet()
.map { code ->
val db = code in inDatabase
val built = code in builtin
OnPackSupportedItemDto(
itemCode = code,
printable = db || built,
inDatabase = db,
builtin = built,
registered = code in registered,
)
}
}
}
}

+ 13
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt 查看文件

@@ -0,0 +1,13 @@
package com.ffii.fpsms.modules.jobOrder.service

internal object OnPackXml {
fun escapeText(value: String): String =
value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

fun nextId(xml: String): Int {
val ids = Regex("""<ID>\s*(\d+)\s*</ID>""")
.findAll(xml)
.mapNotNull { it.groupValues[1].toIntOrNull() }
return (ids.maxOrNull() ?: 7) + 1
}
}

+ 306
- 62
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt 查看文件

@@ -13,6 +13,7 @@ import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest
import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest
import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest
import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.settings.service.SettingsService
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
@@ -56,10 +57,20 @@ import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter

// Data class to store bitmap bytes + width (for XML)
data class BitmapResult(val bytes: ByteArray, val width: Int)

private data class OnPackBmpExportItem(
val codeLower: String,
val itemId: Long,
val stockInLineId: Long,
val itemCode: String,
val productName: String,
val planDate: LocalDate?,
)

/** One Bag2-style laser TCP attempt (internal to [PlasticBagPrinterService]). */
private data class LaserBag2TcpResult(
val success: Boolean,
@@ -75,10 +86,12 @@ class PlasticBagPrinterService(
private val jdbcDao: JdbcDao,
private val stockInLineRepository: StockInLineRepository,
private val itemUomService: ItemUomService,
private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService,
private val settingsService: SettingsService,
private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService,
private val environment: Environment,
private val objectMapper: ObjectMapper,
private val onPackTemplateFileService: OnPackTemplateFileService,
) {
private val logger = LoggerFactory.getLogger(javaClass)
private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong")
@@ -88,6 +101,67 @@ class PlasticBagPrinterService(
private const val DEFAULT_LASER_BAG2_PORT = 45678
private const val DEFAULT_LASER_ITEM_CODES = "PP1175"
private const val PACKAGING_PROCESS_NAME = "包裝"
/** Designer product BMP canvas (LOGO), 1-bit. */
private const val ONPACK_PRODUCT_BMP_WIDTH = 593
private const val ONPACK_PRODUCT_BMP_HEIGHT = 90
/** Designer item-code BMP canvas (LOGO_2), 1-bit. */
private const val ONPACK_CODE_BMP_WIDTH = 385
private const val ONPACK_CODE_BMP_HEIGHT = 141
/** Designer production-date BMP canvas (LOGO_3), 1-bit. */
private const val ONPACK_DATE_BMP_WIDTH = 505
private const val ONPACK_DATE_BMP_HEIGHT = 141
/** Designer expiry BMP canvas (LOGO_5), 1-bit. */
private const val ONPACK_EXPIRY_BMP_WIDTH = 623
private const val ONPACK_EXPIRY_BMP_HEIGHT = 79

/**
* Bag2/Bag4 laser TCP payload. When [expiryDate] is present:
* `json;itemCode;itemName;Expiry Date yyyyMMdd;;`
* otherwise the original 3-field form `json;itemCode;itemName;;`.
*/
fun buildLaserBag2Payload(
itemId: Long?,
stockInLineId: Long?,
itemCode: String?,
itemName: String?,
expiryDate: String? = null,
): String {
val codeStr = (itemCode ?: "").trim().replace(";", ",")
val nameStr = (itemName ?: "").trim().replace(";", ",")
val expStr = formatLaserExpiryParam(expiryDate)
val head = if (itemId != null && stockInLineId != null) {
"{\"itemId\":$itemId,\"stockInLineId\":$stockInLineId}"
} else {
"0"
}
return if (expStr.isNotEmpty()) {
"$head;$codeStr;$nameStr;$expStr;;"
} else {
"$head;$codeStr;$nameStr;;"
}
}

/** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */
fun formatLaserExpiryParam(expiryDate: String?): String {
val raw = expiryDate?.trim().orEmpty()
if (raw.isEmpty()) return ""
if (raw.startsWith("Expiry Date ", ignoreCase = true)) {
return raw.replace(";", ",")
}
val iso = raw.take(10)
try {
return ItemDefaultShelfLifeService.formatPrintLabel(LocalDate.parse(iso))
} catch (_: Exception) {
}
if (raw.length == 8 && raw.all { it.isDigit() }) {
try {
val d = LocalDate.parse(raw, DateTimeFormatter.BASIC_ISO_DATE)
return ItemDefaultShelfLifeService.formatPrintLabel(d)
} catch (_: Exception) {
}
}
return raw.replace(";", ",")
}
}

fun getLaserBag2Settings(): LaserBag2SettingsResponse {
@@ -176,8 +250,22 @@ class PlasticBagPrinterService(
}
val ids = filtered.mapNotNull { it.id }
val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids)
val printDate = ItemDefaultShelfLifeService.today()
val shelfLifeByCode = itemDefaultShelfLifeService.printShelfLifeByItemCodes(
filtered.map { it.bom?.item?.code ?: it.bom?.code },
)
return filtered.map { jo ->
PyJobOrderListMapper.toLaserListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService)
val itemCode = jo.bom?.item?.code ?: jo.bom?.code
val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(itemCode, shelfLifeByCode, printDate)
PyJobOrderListMapper.toLaserListItem(
jo,
printed[jo.id!!],
stockInLineRepository,
itemUomService,
defaultShelfLifeDays = days,
useMinus18 = useMinus18,
expiryDate = expiry,
)
}
}

@@ -203,6 +291,7 @@ class PlasticBagPrinterService(
stockInLineId = request.stockInLineId,
itemCode = request.itemCode,
itemName = request.itemName,
expiryDate = request.expiryDate,
)
val response = if (first.success) {
LaserBag2SendResponse(
@@ -220,6 +309,7 @@ class PlasticBagPrinterService(
stockInLineId = request.stockInLineId,
itemCode = request.itemCode,
itemName = request.itemName,
expiryDate = request.expiryDate,
)
LaserBag2SendResponse(
success = second.success,
@@ -292,14 +382,9 @@ class PlasticBagPrinterService(
stockInLineId: Long?,
itemCode: String?,
itemName: String?,
expiryDate: String? = null,
): LaserBag2TcpResult {
val codeStr = (itemCode ?: "").trim().replace(";", ",")
val nameStr = (itemName ?: "").trim().replace(";", ",")
val payload = if (itemId != null && stockInLineId != null) {
"{\"itemId\":$itemId,\"stockInLineId\":$stockInLineId};$codeStr;$nameStr;;"
} else {
"0;$codeStr;$nameStr;;"
}
val payload = buildLaserBag2Payload(itemId, stockInLineId, itemCode, itemName, expiryDate)
val bytes = payload.toByteArray(StandardCharsets.UTF_8)
var socket: Socket? = null
try {
@@ -564,7 +649,21 @@ class PlasticBagPrinterService(
return baos.toByteArray()
}

fun generateOnPackQrZip(jobOrders: List<OnPackQrJobOrderRequest>): ByteArray {
/**
* 汁水機 OnPack: templates under classpath `onpack2030/{code}.image`.
* Always swaps LOGO_4 to the generated QR BMP.
* When [includeExpiry] is true, also generates product/code/date/expiry BMPs (`{code}Product.bmp`,
* `{code}Code.bmp`, `{code}Date.bmp` from [printDate] / bagPrint filter, `{code}exp.bmp`).
* When [includeExpiry] is true, writes `{code}exp.bmp` into the template expiry slot
* (`LOGO_5` / `LOGO_EXP` if present, otherwise injects `LOGO_EXP`). Prefers
* `onpack2030_exp/{code}.image` over the main `onpack2030` template.
* Old ZIP callers must pass false; they keep `onpack2030` unchanged.
*/
fun generateOnPackQrZip(
jobOrders: List<OnPackQrJobOrderRequest>,
includeExpiry: Boolean = false,
printDate: LocalDate? = null,
): ByteArray {
val normalizedJobOrders = jobOrders
.map {
OnPackQrJobOrderRequest(
@@ -608,31 +707,98 @@ class PlasticBagPrinterService(
?: return@mapNotNull null
val itemId = stockInLine.item?.id ?: return@mapNotNull null
val stockInLineId = stockInLine.id ?: return@mapNotNull null
Triple(codeLower, itemId, stockInLineId)
val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null)
val baseName = jo?.bom?.name ?: stockInLine.item?.name
val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc
val productName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc)
?: codeLower.uppercase()
val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase()
OnPackBmpExportItem(
codeLower,
itemId,
stockInLineId,
itemCode,
productName,
jo?.planStart?.toLocalDate(),
)
}

require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" }

val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet()
val codesUpper = exportItemsRaw.map { it.itemCode }.toSet()
val allowedBmpCodes = codesOnPackMatchingTemplateType(codesUpper, "bmp")
val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.first.uppercase()) }
val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.itemCode) }

require(exportItems.isNotEmpty()) { "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" }

val effectivePrintDate = printDate
?: exportItems.firstNotNullOfOrNull { it.planDate }
?: ItemDefaultShelfLifeService.today()

val baos = ByteArrayOutputStream()
ZipOutputStream(baos).use { zos ->
val addedEntries = linkedSetOf<String>()
exportItems.forEach { (codeLower, itemId, stockInLineId) ->
val imageTemplate = loadOnPackImageTemplateOrNull(codeLower) ?: return@forEach
exportItems.forEach { item ->
val codeLower = item.codeLower
val imageTemplate = loadOnPackImageTemplateOrNull(codeLower, includeExpiry) ?: return@forEach

val qrContent = """{"itemId": $itemId, "stockInLineId": $stockInLineId}"""
val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}"""
// Target approximately 470x389 BMP, but with larger visible QR and very little vertical whitespace.
// Width = 386 + (42 * 2) = 470
// Height = 386 + (1 * 2) = 388 (~389)
val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1)
val qrBmpFileName = "${codeLower}qr.bmp"
val imageFileName = "$codeLower.image"
val imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName)
var imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName)
if (includeExpiry) {
val productFile = "${codeLower}Product.bmp"
val codeFile = "${codeLower}Code.bmp"
val dateFile = "${codeLower}Date.bmp"
val productBmp = createMonochromeBitmapFixed(
item.productName,
ONPACK_PRODUCT_BMP_WIDTH,
ONPACK_PRODUCT_BMP_HEIGHT,
)
val codeBmp = createMonochromeBitmapFixed(
item.itemCode,
ONPACK_CODE_BMP_WIDTH,
ONPACK_CODE_BMP_HEIGHT,
)
val dateBmp = createMonochromeBitmapFixed(
ItemDefaultShelfLifeService.formatProductionDatePrintLabel(effectivePrintDate),
ONPACK_DATE_BMP_WIDTH,
ONPACK_DATE_BMP_HEIGHT,
)
imageContent = withOnPackLogoFile(imageContent, "LOGO", productFile)
imageContent = withOnPackLogoFile(imageContent, "LOGO_2", codeFile)
imageContent = withOnPackLogoFile(imageContent, "LOGO_3", dateFile)
if (addedEntries.add(productFile)) {
addToZip(zos, productFile, productBmp.bytes)
}
if (addedEntries.add(codeFile)) {
addToZip(zos, codeFile, codeBmp.bytes)
}
if (addedEntries.add(dateFile)) {
addToZip(zos, dateFile, dateBmp.bytes)
}
}
val expiryLabel = if (includeExpiry) {
itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate)
} else {
null
}
if (!expiryLabel.isNullOrBlank()) {
val expBmp = createMonochromeBitmapFixed(
expiryLabel,
ONPACK_EXPIRY_BMP_WIDTH,
ONPACK_EXPIRY_BMP_HEIGHT,
)
val expBmpFileName = "${codeLower}exp.bmp"
imageContent = withOnPackExpiryLogo(imageContent, expBmpFileName, expBmp.width)
if (addedEntries.add(expBmpFileName)) {
addToZip(zos, expBmpFileName, expBmp.bytes)
}
}

if (addedEntries.add(qrBmpFileName)) {
addToZip(zos, qrBmpFileName, bmp.bytes)
@@ -640,6 +806,23 @@ class PlasticBagPrinterService(
if (addedEntries.add(imageFileName)) {
addToZip(zos, imageFileName, imageContent)
}
val decodedXmlForAssets = decodeOnPackImageTemplateForTextEdit(imageContent).first
extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName ->
if (bmpName.equals(qrBmpFileName, ignoreCase = true)) return@forEach
if (bmpName.endsWith("exp.bmp", ignoreCase = true)) return@forEach
if (bmpName.endsWith("Product.bmp", ignoreCase = true)) return@forEach
if (bmpName.endsWith("Code.bmp", ignoreCase = true)) return@forEach
if (bmpName.endsWith("Date.bmp", ignoreCase = true)) return@forEach
if (!addedEntries.add(bmpName)) return@forEach
val bmpBytes = loadJuiceAssetOrNull(bmpName) ?: return@forEach
addToZip(zos, bmpName, bmpBytes)
}
val jobFileName = "${codeLower}.job"
loadJuiceAssetOrNull(jobFileName)?.let { jobBytes ->
if (addedEntries.add(jobFileName)) {
addToZip(zos, jobFileName, jobBytes)
}
}
}

require(addedEntries.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" }
@@ -651,8 +834,13 @@ class PlasticBagPrinterService(
/**
* OnPack2023 檸檬機: templates under classpath `onpack2030_2/{code}.image` with embedded QR (Static text).
* Only replaces `<Text>...</Text>` under `<Static type="StaticSrc.V1">` with JSON payload. No separate .bmp in zip.
* When [includeExpiry] is true, also injects TEXT_EXP (does not change TEXT_3 production date). Old ZIP callers must pass false.
*/
fun generateOnPackQrTextZip(jobOrders: List<OnPackQrJobOrderRequest>): ByteArray {
fun generateOnPackQrTextZip(
jobOrders: List<OnPackQrJobOrderRequest>,
includeExpiry: Boolean = false,
printDate: LocalDate? = null,
): ByteArray {
val normalizedJobOrders = jobOrders
.map {
OnPackQrJobOrderRequest(
@@ -711,11 +899,25 @@ class PlasticBagPrinterService(
val addedEntries = linkedSetOf<String>()
exportItems.forEach { (codeLower, itemId, stockInLineId) ->
val imageTemplate = loadOnPack2030_2ImageTemplateOrNull(codeLower) ?: run {
logger.warn("OnPack text ZIP: missing classpath template onpack2030_2/{}.image", codeLower.uppercase())
logger.warn("OnPack text ZIP: missing template for {}", codeLower.uppercase())
return@forEach
}
val imageFileName = "${codeLower.uppercase()}.image"
val imageContent = withOnPackStaticQrText(codeLower, imageTemplate, itemId, stockInLineId)
val expiryLabel = if (includeExpiry) {
itemDefaultShelfLifeService.expiryDatePrintLabel(
codeLower,
printDate ?: ItemDefaultShelfLifeService.today(),
)
} else {
null
}
val imageContent = withOnPackStaticQrText(
codeLower,
imageTemplate,
itemId,
stockInLineId,
expiryCompact = expiryLabel,
)
if (addedEntries.add(imageFileName)) {
addToZip(zos, imageFileName, imageContent)
}
@@ -723,7 +925,7 @@ class PlasticBagPrinterService(
extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName ->
if (!addedEntries.add(bmpName)) return@forEach
val bmpBytes = loadOnPack2030_2AssetOrNull(bmpName) ?: run {
logger.warn("OnPack text ZIP: missing classpath asset onpack2030_2/{}", bmpName)
logger.warn("OnPack text ZIP: missing asset {}", bmpName)
return@forEach
}
addToZip(zos, bmpName, bmpBytes)
@@ -816,10 +1018,26 @@ class PlasticBagPrinterService(
else -> return emptySet()
}
val rows = jdbcDao.queryForList(sql, mapOf("codes" to codesUpper.toList()))
return rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet()
val fromTable = rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet()
val machine = if (normalizedType == "text") {
OnPackTemplateFileService.MACHINE_LEMON
} else {
OnPackTemplateFileService.MACHINE_JUICE
}
val fromDbFiles = onPackTemplateFileService.itemCodesWithImage(machine)
.filter { it in codesUpper }
.toSet()
return fromTable + fromDbFiles
}

private fun loadOnPackImageTemplateOrNull(codeLower: String): ByteArray? {
private fun loadOnPackImageTemplateOrNull(codeLower: String, forExpiry: Boolean = false): ByteArray? {
onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_JUICE, codeLower)?.let { return it }
if (forExpiry) {
val expiryResource = ClassPathResource("onpack2030_exp/${codeLower}.image")
if (expiryResource.exists()) {
return expiryResource.inputStream.use { it.readBytes() }
}
}
val resourcePath = "onpack2030/${codeLower}.image"
val resource = ClassPathResource(resourcePath)
if (!resource.exists()) return null
@@ -828,6 +1046,7 @@ class PlasticBagPrinterService(

/** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */
private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? {
onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_LEMON, codeLower)?.let { return it }
val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image"
val resource = ClassPathResource(resourcePath)
if (!resource.exists()) return null
@@ -836,11 +1055,24 @@ class PlasticBagPrinterService(

private fun loadOnPack2030_2AssetOrNull(fileName: String): ByteArray? {
val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null }
onPackTemplateFileService.loadAsset(OnPackTemplateFileService.MACHINE_LEMON, safe)?.let { return it }
val resource = ClassPathResource("onpack2030_2/$safe")
if (!resource.exists()) return null
return resource.inputStream.use { it.readBytes() }
}

private fun loadJuiceAssetOrNull(fileName: String): ByteArray? {
val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null }
onPackTemplateFileService.loadAsset(OnPackTemplateFileService.MACHINE_JUICE, safe)?.let { return it }
listOf("onpack2030_exp/$safe", "onpack2030/$safe").forEach { path ->
val resource = ClassPathResource(path)
if (resource.exists()) {
return resource.inputStream.use { it.readBytes() }
}
}
return null
}

/** Collect `<FileName>xxx.bmp</FileName>` inside each `<Logo>...</Logo>` block (decoded template XML). */
private fun extractLogoBmpFileNamesFromOnPackImageXml(xml: String): Set<String> {
val out = linkedSetOf<String>()
@@ -853,14 +1085,17 @@ class PlasticBagPrinterService(
}

private fun withOnPackLogo4Bmp(imageBytes: ByteArray, qrBmpFileName: String): ByteArray {
// Use ISO-8859-1 one-byte mapping so all original bytes are preserved,
// while replacing only ASCII XML fragment for LOGO_4 filename.
val oneByteText = String(imageBytes, StandardCharsets.ISO_8859_1)
val replaced = oneByteText.replace(
Regex("""(<Name>\s*LOGO_4\s*</Name>[\s\S]*?<FileName>)([^<]+)(</FileName>)"""),
"$1$qrBmpFileName$3",
)
return replaced.toByteArray(StandardCharsets.ISO_8859_1)
return withOnPackLogoFile(imageBytes, "LOGO_4", qrBmpFileName)
}

private fun withOnPackLogoFile(imageBytes: ByteArray, fieldName: String, bmpFileName: String): ByteArray {
val (xml, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes)
return encodeBack(OnPackJuiceExpiryXml.rewriteLogoFileName(xml, fieldName, bmpFileName))
}

private fun withOnPackExpiryLogo(imageBytes: ByteArray, bmpFileName: String, bmpWidth: Int): ByteArray {
val (xml, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes)
return encodeBack(OnPackJuiceExpiryXml.applyExpiry(xml, bmpFileName, bmpWidth))
}

/**
@@ -868,39 +1103,16 @@ class PlasticBagPrinterService(
* substring/regex matching (`hasNameQr=false` while XML is valid).
*/
private fun decodeOnPackImageTemplateForTextEdit(bytes: ByteArray): Pair<String, (String) -> ByteArray> {
val bomUtf16Le = byteArrayOf(0xFF.toByte(), 0xFE.toByte())
val bomUtf16Be = byteArrayOf(0xFE.toByte(), 0xFF.toByte())
val bomUtf8 = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())

when {
bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] -> {
val body = bytes.copyOfRange(2, bytes.size)
val text = String(body, StandardCharsets.UTF_16LE)
return text to { s -> bomUtf16Le + s.toByteArray(StandardCharsets.UTF_16LE) }
}
bytes.size >= 2 && bytes[0] == bomUtf16Be[0] && bytes[1] == bomUtf16Be[1] -> {
val body = bytes.copyOfRange(2, bytes.size)
val text = String(body, StandardCharsets.UTF_16BE)
return text to { s -> bomUtf16Be + s.toByteArray(StandardCharsets.UTF_16BE) }
}
bytes.size >= 3 && bytes[0] == bomUtf8[0] && bytes[1] == bomUtf8[1] && bytes[2] == bomUtf8[2] -> {
val body = bytes.copyOfRange(3, bytes.size)
val text = String(body, StandardCharsets.UTF_8)
return text to { s -> bomUtf8 + s.toByteArray(StandardCharsets.UTF_8) }
}
// UTF-16 LE without BOM: "<" == 0x3C 0x00
bytes.size >= 2 && bytes[0] == 0x3C.toByte() && bytes[1] == 0x00.toByte() -> {
val text = String(bytes, StandardCharsets.UTF_16LE)
return text to { s -> s.toByteArray(StandardCharsets.UTF_16LE) }
}
else -> {
val utf8 = String(bytes, StandardCharsets.UTF_8)
return utf8 to { s -> s.toByteArray(StandardCharsets.UTF_8) }
}
}
return OnPackImageTemplateCodec.decode(bytes)
}

private fun withOnPackStaticQrText(forCode: String, imageBytes: ByteArray, itemId: Long, stockInLineId: Long): ByteArray {
private fun withOnPackStaticQrText(
forCode: String,
imageBytes: ByteArray,
itemId: Long,
stockInLineId: Long,
expiryCompact: String? = null,
): ByteArray {
val payload = """{"itemId": $itemId, "stockInLineId": $stockInLineId}"""
val (oneByteText, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes)
// Must NOT use Static[^>]*StaticSrc — [^>]* already eats type='StaticSrc.V1', so StaticSrc can never match.
@@ -934,7 +1146,12 @@ class PlasticBagPrinterService(
)
}
val withQrCellWidth = replaceQrV1CellWidth67To50(replaced)
return encodeBack(withQrCellWidth)
val withExpiry = if (expiryCompact.isNullOrBlank()) {
withQrCellWidth
} else {
OnPackLemonExpiryXml.applyExpiry(withQrCellWidth, expiryCompact)
}
return encodeBack(withExpiry)
}

/** First `<CellWidth>67</CellWidth>` inside `<QR type='QR.V1'>` → 50 (export tuning). */
@@ -952,6 +1169,33 @@ class PlasticBagPrinterService(
return if (collapsed.length <= maxLen) collapsed else collapsed.take(maxLen) + "…"
}

/** 1-bit BMP at an exact canvas (no crop). Used for OnPack expiry so it matches the designer slot. */
private fun createMonochromeBitmapFixed(text: String, width: Int, height: Int): BitmapResult {
val img = BufferedImage(width, height, BufferedImage.TYPE_BYTE_BINARY)
img.createGraphics().apply {
color = Color.WHITE
fillRect(0, 0, width, height)
color = Color.BLACK
setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF)
var fontSize = (height * 0.92).toInt().coerceAtLeast(1)
var font = Font("SimSun", Font.BOLD, fontSize)
var metrics = getFontMetrics(font)
while (fontSize > 8 && metrics.stringWidth(text) > width) {
fontSize--
font = Font("SimSun", Font.BOLD, fontSize)
metrics = getFontMetrics(font)
}
this.font = font
val x = 0
val y = metrics.ascent + ((height - metrics.height) / 2).coerceAtLeast(0)
drawString(text, x, y)
dispose()
}
val baos = ByteArrayOutputStream()
ImageIO.write(img, "bmp", baos)
return BitmapResult(baos.toByteArray(), img.width)
}

private fun createMonochromeBitmap(text: String, targetHeight: Int): BitmapResult {
// Step 1: Measure text width with temporary image
val tempImg = BufferedImage(1, 1, BufferedImage.TYPE_BYTE_BINARY)


+ 55
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt 查看文件

@@ -0,0 +1,55 @@
package com.ffii.fpsms.modules.jobOrder.web

import com.ffii.fpsms.modules.jobOrder.service.OnPackTemplateFileService
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedCatalogDto
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
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.multipart.MultipartFile

@RestController
@RequestMapping("/plastic/onpack-templates")
class OnPackTemplateController(
private val onPackTemplateFileService: OnPackTemplateFileService,
) {
@GetMapping
fun list(
@RequestParam(required = false) machine: String?,
): List<OnPackTemplateFileDto> {
return onPackTemplateFileService.list(machine)
}

@GetMapping("/supported")
fun supported(): OnPackSupportedCatalogDto {
return onPackTemplateFileService.supportedCatalog()
}

@PostMapping
fun upload(
@RequestParam machine: String,
@RequestParam itemCode: String,
@RequestParam("files") files: List<MultipartFile>?,
): ResponseEntity<OnPackTemplateUploadResponse> {
return ResponseEntity.ok(onPackTemplateFileService.upload(machine, itemCode, files ?: emptyList()))
}

@DeleteMapping("/{id}")
fun delete(@PathVariable id: Long): ResponseEntity<Void> {
onPackTemplateFileService.softDelete(id)
return ResponseEntity.noContent().build()
}

@ExceptionHandler(IllegalArgumentException::class)
fun badRequest(e: IllegalArgumentException): ResponseEntity<Map<String, String>> {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("message" to (e.message ?: "Invalid request")))
}
}

+ 91
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt 查看文件

@@ -60,7 +60,8 @@ class PlasticBagPrinterController(
}

/**
* Bag2.py laser TCP protocol: `{"itemId":n,"stockInLineId":m};code;name;;` or `0;code;name;;`
* Bag2/Bag4 laser TCP protocol: `{"itemId":n,"stockInLineId":m};code;name;;` or
* `{"itemId":n,"stockInLineId":m};code;name;Expiry Date yyyyMMdd;;` when expiryDate is set.
*/
@PostMapping("/print-laser-bag2")
fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity<LaserBag2SendResponse> {
@@ -148,6 +149,51 @@ class PlasticBagPrinterController(
}
}

/**
* Same 汁水機 ZIP as [downloadOnPackQr], plus expiry BMP from item_default_shelf_life.
* Uses designer `.image` layout (`LOGO_5` / `LOGO_EXP`) when present; otherwise injects LOGO_EXP.
* Old [downloadOnPackQr] is unchanged.
*/
@PostMapping("/download-onpack-qr-with-expiry")
fun downloadOnPackQrWithExpiry(
@RequestBody request: OnPackQrDownloadRequest,
response: HttpServletResponse,
) {
try {
val zipBytes = plasticBagPrinterService.generateOnPackQrZip(
request.jobOrders,
includeExpiry = true,
printDate = request.planDate,
)
response.contentType = "application/zip"
response.setHeader(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"onpack_qr_exp.zip\""
)
response.setContentLength(zipBytes.size)
response.outputStream.write(zipBytes)
response.outputStream.flush()
} catch (e: IllegalArgumentException) {
response.status = HttpServletResponse.SC_BAD_REQUEST
response.contentType = "text/plain;charset=UTF-8"
response.writer.write(e.message ?: "Invalid request")
response.writer.flush()
} catch (e: Exception) {
logger.error("POST /plastic/download-onpack-qr-with-expiry failed", e)
try {
if (!response.isCommitted) {
response.reset()
}
} catch (_: Exception) {
/* ignore */
}
response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR
response.contentType = "text/plain;charset=UTF-8"
response.writer.write(e.message ?: "Download failed")
response.writer.flush()
}
}

/** OnPack2023 檸檬機: `onpack2030_2` templates with embedded QR (Static text only; no separate .bmp). */
@PostMapping("/download-onpack-qr-text")
fun downloadOnPackQrText(
@@ -194,6 +240,50 @@ class PlasticBagPrinterController(
return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders))
}

/**
* Same lemon OnPack ZIP as [downloadOnPackQrText], plus TEXT_EXP from item_default_shelf_life.
* Does not replace TEXT_3 (production / print date). Old [downloadOnPackQrText] is unchanged.
*/
@PostMapping("/download-onpack-qr-text-with-expiry")
fun downloadOnPackQrTextWithExpiry(
@RequestBody request: OnPackQrDownloadRequest,
response: HttpServletResponse,
) {
try {
val zipBytes = plasticBagPrinterService.generateOnPackQrTextZip(
request.jobOrders,
includeExpiry = true,
printDate = request.planDate,
)
response.contentType = "application/zip"
response.setHeader(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"onpack2023_lemon_qr_exp.zip\""
)
response.setContentLength(zipBytes.size)
response.outputStream.write(zipBytes)
response.outputStream.flush()
} catch (e: IllegalArgumentException) {
response.status = HttpServletResponse.SC_BAD_REQUEST
response.contentType = "text/plain;charset=UTF-8"
response.writer.write(e.message ?: "Invalid request")
response.writer.flush()
} catch (e: Exception) {
logger.error("POST /plastic/download-onpack-qr-text-with-expiry failed", e)
try {
if (!response.isCommitted) {
response.reset()
}
} catch (_: Exception) {
/* ignore */
}
response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR
response.contentType = "text/plain;charset=UTF-8"
response.writer.write(e.message ?: "Download failed")
response.writer.flush()
}
}

/**
* Test API to generate and download the printer job files as a ZIP.
* ONPACK2030


+ 7
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt 查看文件

@@ -1,7 +1,8 @@
package com.ffii.fpsms.modules.jobOrder.web.model

/**
* Body for Bag2.py-style laser TCP send: `json;itemCode;itemName;;` (UTF-8).
* Body for Bag2/Bag4-style laser TCP send: `json;itemCode;itemName;;` or
* `json;itemCode;itemName;Expiry Date yyyyMMdd;;` when [expiryDate] is set (UTF-8).
* Optional [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port].
*
* Optional job metadata is used to persist [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS]
@@ -17,6 +18,11 @@ data class LaserBag2SendRequest(
val jobOrderId: Long? = null,
val jobOrderNo: String? = null,
val lotNo: String? = null,
/**
* Print-time expiry from the job-order list (`yyyy-MM-dd`, compact `yyyyMMdd`,
* or already `Expiry Date yyyyMMdd`). Sent as the 4th TCP field.
*/
val expiryDate: String? = null,
/** AUTO (auto-send) or MANUAL (/laserPrint); optional. */
val source: String? = null,
)

+ 29
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt 查看文件

@@ -0,0 +1,29 @@
package com.ffii.fpsms.modules.jobOrder.web.model

data class OnPackTemplateFileDto(
val id: Long,
val machine: String,
val itemCode: String,
val fileName: String,
val byteSize: Int,
val modified: String?,
)

data class OnPackTemplateUploadResponse(
val machine: String,
val itemCode: String,
val saved: List<String>,
)

data class OnPackSupportedItemDto(
val itemCode: String,
val printable: Boolean,
val inDatabase: Boolean,
val builtin: Boolean,
val registered: Boolean,
)

data class OnPackSupportedCatalogDto(
val juice: List<OnPackSupportedItemDto>,
val lemon: List<OnPackSupportedItemDto>,
)

+ 2
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt 查看文件

@@ -52,6 +52,8 @@ data class PrinterStatusResponse(

data class OnPackQrDownloadRequest(
val jobOrders: List<OnPackQrJobOrderRequest>,
/** /bagPrint filter date (job plan date). Used by expiry ZIP for LOGO_3 production date. */
val planDate: java.time.LocalDate? = null,
)

data class OnPackQrJobOrderRequest(


+ 43
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt 查看文件

@@ -0,0 +1,43 @@
package com.ffii.fpsms.modules.master.entity

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

/**
* Shelf life by item code for bag / OnPack expiry print.
* [useMinus18]: 0 = print uses [defaultDays] (chilled); 1 = print uses [minus18Days].
*/
@Entity
@Table(name = "item_default_shelf_life")
open class ItemDefaultShelfLife : BaseEntity<Long>() {

@NotNull
@Size(max = 50)
@Column(name = "itemCode", length = 50, nullable = false, unique = true)
open var itemCode: String? = null

@Column(name = "defaultDays")
open var defaultDays: Int? = null

@Column(name = "minus18Days")
open var minus18Days: Int? = null

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

@Column(name = "openedDays")
open var openedDays: Int? = null

@Size(max = 20)
@Column(name = "storageC", length = 20)
open var storageC: String? = null

@Size(max = 255)
@Column(name = "remarks", length = 255)
open var remarks: String? = null
}

+ 12
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt 查看文件

@@ -0,0 +1,12 @@
package com.ffii.fpsms.modules.master.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.stereotype.Repository

@Repository
interface ItemDefaultShelfLifeRepository : AbstractRepository<ItemDefaultShelfLife, Long> {

fun findByDeletedFalseAndItemCodeIgnoreCase(itemCode: String): ItemDefaultShelfLife?

fun findByDeletedFalseAndItemCodeIn(itemCodes: Collection<String>): List<ItemDefaultShelfLife>
}

+ 83
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt 查看文件

@@ -0,0 +1,83 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLife
import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLifeRepository
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter

data class ItemPrintShelfLife(
val effectiveDays: Int,
val useMinus18: Boolean,
)

/**
* Lookup shelf life days and compute print-date expiry.
* Uses [ItemDefaultShelfLife.useMinus18] to pick chilled [defaultDays] vs [minus18Days].
*/
@Service
class ItemDefaultShelfLifeService(
private val repository: ItemDefaultShelfLifeRepository,
) {
fun printShelfLifeByItemCodes(codes: Collection<String?>): Map<String, ItemPrintShelfLife> {
val normalized = codes.mapNotNull { it?.trim()?.uppercase()?.takeIf { code -> code.isNotEmpty() } }
.distinct()
if (normalized.isEmpty()) return emptyMap()
return repository.findByDeletedFalseAndItemCodeIn(normalized)
.mapNotNull { row ->
val code = row.itemCode?.trim()?.uppercase().orEmpty()
val days = effectiveDays(row) ?: return@mapNotNull null
if (code.isEmpty()) null else code to ItemPrintShelfLife(days, row.useMinus18 == true)
}
.toMap()
}

fun defaultDaysByItemCodes(codes: Collection<String?>): Map<String, Int> =
printShelfLifeByItemCodes(codes).mapValues { it.value.effectiveDays }

fun defaultDays(itemCode: String?): Int? {
val code = itemCode?.trim()?.uppercase().orEmpty()
if (code.isEmpty()) return null
val row = repository.findByDeletedFalseAndItemCodeIgnoreCase(code) ?: return null
return effectiveDays(row)
}

fun expiryDate(itemCode: String?, printDate: LocalDate = today()): LocalDate? {
val days = defaultDays(itemCode) ?: return null
return expiryOn(printDate, days)
}

fun expiryDateIso(itemCode: String?, printDate: LocalDate = today()): String? =
expiryDate(itemCode, printDate)?.toString()

fun expiryDateCompact(itemCode: String?, printDate: LocalDate = today()): String? =
expiryDate(itemCode, printDate)?.format(COMPACT)

/** Printed bag wording, e.g. `Expiry Date 20260821`. */
fun expiryDatePrintLabel(itemCode: String?, printDate: LocalDate = today()): String? =
expiryDate(itemCode, printDate)?.let { formatPrintLabel(it) }

companion object {
val PRINT_ZONE: ZoneId = ZoneId.of("Asia/Hong_Kong")
private val COMPACT: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE

fun today(): LocalDate = LocalDate.now(PRINT_ZONE)

fun formatPrintLabel(d: LocalDate): String = "Expiry Date ${d.format(COMPACT)}"

/** Production / print date on 汁水機 LOGO_3, e.g. `20260821`. */
fun formatProductionDatePrintLabel(d: LocalDate): String = d.format(COMPACT)

fun expiryOn(printDate: LocalDate, defaultDays: Int): LocalDate =
printDate.plusDays(defaultDays.toLong())

fun effectiveDays(row: ItemDefaultShelfLife): Int? =
effectiveDays(row.defaultDays, row.minus18Days, row.useMinus18 == true)

fun effectiveDays(defaultDays: Int?, minus18Days: Int?, useMinus18: Boolean): Int? {
val chosen = if (useMinus18) minus18Days else defaultDays
return chosen?.takeIf { it > 0 }
}
}
}

+ 33
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt 查看文件

@@ -0,0 +1,33 @@
package com.ffii.fpsms.modules.master.service

/**
* Production-safe rules for linking a new M18 product id onto an existing local item
* (same code+type after M18 recoded the product).
*
* Remap must only update [com.ffii.fpsms.modules.master.entity.Items.m18Id] — never a full
* product overwrite or item_uom rebuild (those delete purchase/stock UOMs).
*/
internal object ItemM18IdRemapSupport {
const val LINKED_MESSAGE = "Linked m18Id to existing item with same code"

/**
* True when saveItem may attach [requestM18Id] to [duplicatedItemId] instead of
* returning "code already existed".
*/
fun canLinkM18IdToDuplicateCode(
requestId: Long?,
requestM18Id: Long?,
duplicatedItemId: Long?,
ownerOfNewM18IdItemId: Long?,
): Boolean {
if (requestM18Id == null || duplicatedItemId == null) return false
if (requestId != null && requestId != duplicatedItemId) return false
if (ownerOfNewM18IdItemId != null && ownerOfNewM18IdItemId != duplicatedItemId) return false
return true
}

/** After saveItem: M18 pull missed by m18Id, then linked by code+type only. */
fun isM18IdLinkOnly(existingByM18IdWasMissing: Boolean, message: String?): Boolean {
return existingByM18IdWasMissing && message == LINKED_MESSAGE
}
}

+ 55
- 18
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt 查看文件

@@ -581,6 +581,10 @@ open class ItemsService(
return itemsRepository.findByCodeAndDeletedFalse(code);
}

open fun findByCodeAndType(code: String, type: String): Items? {
return itemsRepository.findByCodeAndTypeAndDeletedFalse(code, type)
}

open fun findByM18Id(m18Id: Long): Items? {
return itemsRepository.findByM18IdAndDeletedIsFalse(m18Id)
}
@@ -650,34 +654,67 @@ open class ItemsService(
@Transactional
open fun saveItem(request: NewItemRequest): MessageResponse {
val duplicatedItem = itemsRepository.findByCodeAndTypeAndDeletedFalse(request.code, request.type)
val ownerOfNewM18Id = request.m18Id?.let { findByM18Id(it) }
if (duplicatedItem != null && duplicatedItem.id != request.id) {
if (request.m18Id != null && request.id == null && duplicatedItem.m18Id == null) {
duplicatedItem.m18Id = request.m18Id
duplicatedItem.m18LastModifyDate = request.m18LastModifyDate
val linked = itemsRepository.saveAndFlush(duplicatedItem)
val canLink = ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode(
requestId = request.id,
requestM18Id = request.m18Id,
duplicatedItemId = duplicatedItem.id,
ownerOfNewM18IdItemId = ownerOfNewM18Id?.id,
)
if (!canLink) {
return MessageResponse(
id = linked.id,
code = linked.code,
name = linked.name,
type = linked.type.toString(),
message = "Linked m18Id to existing item with same code",
errorPosition = null,
id = request.id ?: duplicatedItem.id,
code = request.code,
name = request.name,
type = request.type.toString(),
message = "The item code has already existed",
errorPosition = "code"
)
}
// M18 recode: attach new proId only. Do not overwrite name/QC/UOM via this path.
if (duplicatedItem.m18Id != request.m18Id) {
logger.warn(
"Remapping item m18Id: localId=${duplicatedItem.id} code=${duplicatedItem.code} oldM18Id=${duplicatedItem.m18Id} newM18Id=${request.m18Id}"
)
}
duplicatedItem.m18Id = request.m18Id
duplicatedItem.m18LastModifyDate = request.m18LastModifyDate ?: duplicatedItem.m18LastModifyDate
val linked = itemsRepository.saveAndFlush(duplicatedItem)
return MessageResponse(
id = linked.id,
code = linked.code,
name = linked.name,
type = linked.type.toString(),
message = ItemM18IdRemapSupport.LINKED_MESSAGE,
errorPosition = null,
)
}
// Prefer local id so an existing item can have its m18Id updated from the UI.
val item = when {
request.id != null && request.id > 0 ->
itemsRepository.findByIdAndDeletedFalse(request.id) ?: Items()
ownerOfNewM18Id != null -> ownerOfNewM18Id
else -> Items()
}
logger.info("item: $item")
if (ownerOfNewM18Id != null && ownerOfNewM18Id.id != item.id) {
return MessageResponse(
id = request.id ?: duplicatedItem.id,
id = request.id ?: item.id,
code = request.code,
name = request.name,
type = request.type.toString(),
message = "The item code has already existed",
errorPosition = "code"
message = "M18 ID already used by another item",
errorPosition = "m18Id"
)
}
val item = if (request.m18Id != null) findByM18Id(request.m18Id) ?: Items()
else if (request.id != null && request.id > 0) itemsRepository.findByIdAndDeletedFalse(request.id) ?: Items()
else Items()
logger.info("item: $item")
if (item.m18LastModifyDate == request.m18LastModifyDate) {
val m18IdUnchanged = request.m18Id == null || item.m18Id == request.m18Id
// Skip only for unchanged M18 pulls (request carries lastModifyDate). UI saves do not.
if (request.m18LastModifyDate != null &&
item.id != null &&
item.m18LastModifyDate == request.m18LastModifyDate &&
m18IdUnchanged
) {
return MessageResponse(
id = item.id,
code = item.code,


+ 17
- 1
src/main/java/com/ffii/fpsms/py/PyController.kt 查看文件

@@ -2,6 +2,7 @@ package com.ffii.fpsms.py

import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService
import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import org.springframework.format.annotation.DateTimeFormat
@@ -28,6 +29,7 @@ open class PyController(
private val itemUomService: ItemUomService,
private val plasticBagPrinterService: PlasticBagPrinterService,
private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService,
private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService,
) {
companion object {
private const val PACKAGING_PROCESS_NAME = "包裝"
@@ -53,8 +55,22 @@ open class PyController(
)
val ids = orders.mapNotNull { it.id }
val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids)
val printDate = ItemDefaultShelfLifeService.today()
val shelfLifeByCode = itemDefaultShelfLifeService.printShelfLifeByItemCodes(
orders.map { it.bom?.item?.code ?: it.bom?.code },
)
val list = orders.map { jo ->
PyJobOrderListMapper.toListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService)
val itemCode = jo.bom?.item?.code ?: jo.bom?.code
val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(itemCode, shelfLifeByCode, printDate)
PyJobOrderListMapper.toListItem(
jo,
printed[jo.id!!],
stockInLineRepository,
itemUomService,
defaultShelfLifeDays = days,
useMinus18 = useMinus18,
expiryDate = expiry,
)
}
return ResponseEntity.ok(list)
}


+ 7
- 0
src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt 查看文件

@@ -1,6 +1,7 @@
package com.ffii.fpsms.py

import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime

/**
@@ -27,4 +28,10 @@ data class PyJobOrderListItem(
val labelPrintedQty: Long = 0,
/** Cumulative qty from 激光機 submits (LASER). */
val laserPrintedQty: Long = 0,
/** Effective shelf life days used for print (chilled or -18, according to useMinus18). */
val defaultShelfLifeDays: Int? = null,
/** True when expiry is computed from minus18Days instead of chilled defaultDays. */
val useMinus18: Boolean? = null,
/** Print date (today, Asia/Hong_Kong) + effective shelf life days. */
val expiryDate: LocalDate? = null,
)

+ 26
- 0
src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt 查看文件

@@ -1,8 +1,11 @@
package com.ffii.fpsms.py

import com.ffii.fpsms.modules.jobOrder.entity.JobOrder
import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService
import com.ffii.fpsms.modules.master.service.ItemPrintShelfLife
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import java.time.LocalDate

object PyJobOrderListMapper {

@@ -80,6 +83,9 @@ object PyJobOrderListMapper {
printed: PrintedQtyByChannel?,
stockInLineRepository: StockInLineRepository,
itemUomService: ItemUomService,
defaultShelfLifeDays: Int? = null,
useMinus18: Boolean? = null,
expiryDate: LocalDate? = null,
): PyJobOrderListItem {
val itemCode = jo.bom?.item?.code ?: jo.bom?.code
val baseName = jo.bom?.name ?: jo.bom?.item?.name
@@ -107,6 +113,9 @@ object PyJobOrderListMapper {
bagPrintedQty = p.bagPrintedQty,
labelPrintedQty = p.labelPrintedQty,
laserPrintedQty = p.laserPrintedQty,
defaultShelfLifeDays = defaultShelfLifeDays,
useMinus18 = useMinus18,
expiryDate = expiryDate,
)
}

@@ -116,6 +125,9 @@ object PyJobOrderListMapper {
printed: PrintedQtyByChannel?,
stockInLineRepository: StockInLineRepository,
itemUomService: ItemUomService,
defaultShelfLifeDays: Int? = null,
useMinus18: Boolean? = null,
expiryDate: LocalDate? = null,
): PyJobOrderListItem {
val itemCode = jo.bom?.item?.code ?: jo.bom?.code
val baseName = jo.bom?.name ?: jo.bom?.item?.name
@@ -143,6 +155,20 @@ object PyJobOrderListMapper {
bagPrintedQty = p.bagPrintedQty,
labelPrintedQty = p.labelPrintedQty,
laserPrintedQty = p.laserPrintedQty,
defaultShelfLifeDays = defaultShelfLifeDays,
useMinus18 = useMinus18,
expiryDate = expiryDate,
)
}

fun shelfLifeForItem(
itemCode: String?,
byCode: Map<String, ItemPrintShelfLife>,
printDate: LocalDate = ItemDefaultShelfLifeService.today(),
): Triple<Int?, Boolean?, LocalDate?> {
val info = byCode[itemCode?.trim()?.uppercase().orEmpty()]
val days = info?.effectiveDays
val expiry = days?.let { ItemDefaultShelfLifeService.expiryOn(printDate, it) }
return Triple(days, info?.useMinus18, expiry)
}
}

+ 21
- 0
src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql 查看文件

@@ -0,0 +1,21 @@
--liquibase formatted sql

--changeset fpsms:create_item_default_shelf_life
--comment: Default (non -18 warehouse) shelf life days by item code for bag / OnPack expiry print
CREATE TABLE `item_default_shelf_life`
(
`id` INT 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',
`itemCode` VARCHAR(50) NOT NULL,
`defaultDays` INT NOT NULL COMMENT 'Main shelf life days; not -18 warehouse',
`openedDays` INT NULL COMMENT '0-4C secondary; unused for print for now',
`storageC` VARCHAR(20) NULL,
`remarks` VARCHAR(255) NULL,
CONSTRAINT pk_item_default_shelf_life PRIMARY KEY (`id`),
UNIQUE KEY uk_item_default_shelf_life_itemCode (`itemCode`)
);

+ 17
- 0
src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql 查看文件

@@ -0,0 +1,17 @@
--liquibase formatted sql

--changeset fpsms:seed_item_default_shelf_life_sample
--comment: Sample F-codes from the bag print shelf-life sheet (default days only; not -18). Import remaining codes later.
INSERT INTO `item_default_shelf_life`
(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, `itemCode`, `defaultDays`, `openedDays`, `storageC`, `remarks`)
VALUES
(NOW(), 'system', 0, NOW(), 'system', 0, 'F0013', 365, NULL, '18', 'default; not -18 warehouse'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'F0015', 730, NULL, '18', 'default; not -18 warehouse'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'F0025', 365, 10, '0-5', 'default; not -18 warehouse'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'F0028', 730, 12, '0-5', 'default; not -18 warehouse'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'F0032', 365, 12, '0-5', 'default; not -18 warehouse'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'F0039', 365, 12, '0-5', 'default; not -18 warehouse'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'F0055', 365, 7, '0-5', 'default; not -18 warehouse'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'F0075', 180, 14, '0-5', 'default; not -18 warehouse'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'F0110', 90, 21, '0-5', 'default; not -18 warehouse'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'F0250', 365, 12, '0-5', 'default; not -18 warehouse');

+ 181
- 0
src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql 查看文件

@@ -0,0 +1,181 @@
--liquibase formatted sql

--changeset fpsms:seed_item_default_shelf_life_from_joexpiry
--comment: Seed item_default_shelf_life from joExpiry.xlsx sheet XXXXXXXX; defaultDays = first non -18 保質期(P+)
-- generated 2026-08-20; skipped -18-only: 55
INSERT INTO `item_default_shelf_life`
(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, `itemCode`, `defaultDays`, `openedDays`, `storageC`, `remarks`)
VALUES
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2240', 60, NULL, '0-4', '小菜類 TOA韓式泡菜'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2241', 60, NULL, '0-4', '小菜類 TOA酸甜蘿蔔'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2327', 30, NULL, '0-4', '小菜類 OEM香辣炒菜莆(1KG/包)(加工)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2253', 14, NULL, '0-4', '小菜類 涼拌雙絲(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2244', 14, NULL, '0-4', '小菜類 酸甜蘿蔔粒(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2298', 14, NULL, '0-4', '小菜類 炒筍絲(200G)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2018', 14, NULL, '0-4', '小菜類 炸菜肉絲(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2008', 14, NULL, '0-4', '小菜類 雪菜肉絲(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2058', 14, NULL, '0-4', '小菜類 (熟)大冬菇'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2364', 14, NULL, '0-4', '小菜類 炒雪菜(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2376', 14, NULL, '0-4', '小菜類 切冬菇粒(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2383', 14, NULL, '0-4', '小菜類 辣椒菜脯(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1152', 30, NULL, '0-4', '汁水類 魚露味水(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2274', 14, NULL, '0-4', '汁水類 西檸汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2284', 14, NULL, '0-4', '汁水類 油醋汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1225', 14, NULL, '0-4', '汁水類 香辣薑汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1178', 30, NULL, '0-4', '汁水類 撈麵豉油(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1043', 14, NULL, '0-4', '汁水類 豆豉汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2206', 14, NULL, '0-4', '汁水類 鮮沙薑汁(雞廠)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1126', 14, NULL, '0-4', '汁水類 石頭鍋汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1213', 30, NULL, '0-4', '汁水類 石頭飯辣汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1137', 14, NULL, '0-4', '汁水類 咖喱魚蛋汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1117', 14, NULL, '0-4', '汁水類 漁樂紅咖哩汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2262', 7, NULL, '0-4', '汁水類 菇奶(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2250', 30, NULL, '0-4', '汁水類 欖角汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1214', 30, NULL, '0-4', '汁水類 炒粉絲汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1209', 30, NULL, '0-4', '汁水類 扒飯豉油'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2243', 7, NULL, '0-4', '汁水類 粟米汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2210', 7, NULL, '0-4', '汁水類 牛奶薑汁酒'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2214', 30, NULL, '0-4', '汁水類 丼飯汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1224', 14, NULL, '0-4', '汁水類 柚子蒜蓉汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1216', 14, NULL, '0-4', '汁水類 叉燒蜜汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1144', 7, NULL, '0-4', '汁水類 越式汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1080', 14, NULL, '0-4', '汁水類 咖哩汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2211', 14, NULL, '0-4', '汁水類 瑤柱鮑汁 (蠔油汁)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1234', 14, NULL, '0-4', '汁水類 日式咖哩汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2317', 14, NULL, '0-4', '汁水類 漁樂黃咖哩汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2330', 60, NULL, '0-4', '汁水類 浸雞水(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2360', 14, NULL, '0-4', '汁水類 瑞士汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2333', 14, NULL, '0-4', '汁水類 豬鞍汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2331', 14, NULL, '0-4', '汁水類 青咖哩(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2381', 14, NULL, '0-4', '汁水類 咖哩豬皮(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2088', 14, NULL, '0-4', '肉食類 淨牛腩粒(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2271', 90, NULL, '0-4', '肉食類 豬腳薑(350g/包) - 台嵐款'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2290', 14, NULL, '0-4', '肉食類 豬軟骨(400G/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2017', 14, NULL, '0-4', '肉食類 咸瘦肉絲'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2098', 60, NULL, '0-4', '肉食類 臘腸粒(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2127', 14, NULL, '0-4', '肉食類 五香肉丁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2105', 14, NULL, '0-4', '肉食類 咖喱牛腩(1人份量/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2037', 14, NULL, '0-4', '肉食類 柱侯牛筋腩(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2115', 14, NULL, '0-4', '肉食類 瑞士雞翼(1人份量/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2193', 14, NULL, '0-4', '肉食類 原條牛奶叉燒'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2063', 14, NULL, '0-4', '肉食類 咖哩牛腩(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2167', 14, NULL, '0-4', '肉食類 牛丼'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2334', 14, NULL, '0-4', '肉食類 原條牛奶叉燒(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2366', 14, NULL, '0-4', '肉食類 清湯牛腩'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2378', 14, NULL, '0-4', '肉食類 醬香鳳爪(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2325', 7, NULL, '0-4', '沙律 薯仔蛋沙律(2.2KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2300', 60, NULL, '0-4', '油類 凱撒牛油(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1088', 14, NULL, '0-4', '油類 海南雞油(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1118', 14, NULL, '0-4', '油類 漁樂煉豬油'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1185', 14, NULL, '0-4', '油類 蒜香牛油'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1120', 14, NULL, '0-4', '油類 漁樂蝦頭油(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2277', 3, NULL, '0-4', '粉麵﹑飯類 烚意粉(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2276', 3, NULL, '0-4', '粉麵﹑飯類 烚通粉(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1136', 14, NULL, '0-4', '粉麵﹑飯類 白粥'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1133', 14, NULL, '0-4', '粉麵﹑飯類 豬骨粥(1位份量)(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2363', 14, NULL, '0-4', '粉麵﹑飯類 炒麵底(170G/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2228', 60, NULL, '0-4', '湯類 香濃沙嗲湯膽(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1180', 7, NULL, '0-4', '湯類 菇湯(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2246', 14, NULL, '0-4', '湯類 酸辣湯(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2282', 14, NULL, '0-4', '湯類 冷麵湯(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2248', 7, NULL, '0-4', '湯類 無添加魚湯(400ml/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2328', 7, NULL, '0-4', '湯類 鹿茸菇湯(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2332', 14, NULL, '0-4', '湯類 雞絲碗仔翅(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2329', 14, NULL, '0-4', '湯類 茶樹菇排骨湯(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2288', 3, NULL, '0-4', '飲品類 香水檸檬汁P+3(0.8L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1175', 4, NULL, '0-4', '飲品類 鮮檸檬汁(P+4)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2237', 10, NULL, '0-4', '飲品類 (樽裝用)凍咖啡底P+10(0.9L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2238', 10, NULL, '0-4', '飲品類 (餐廳用)凍咖啡底P+10(0.9L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2236', 10, NULL, '0-4', '飲品類 (樽裝用)凍奶茶底P+10(1L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2239', 10, NULL, '0-4', '飲品類 (餐廳用)凍奶茶底P+10(1L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2336', 10, NULL, '0-4', '飲品類 (無糖)凍咖啡底P+10(0.9L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2338', 10, NULL, '0-4', '飲品類 (無糖)凍奶茶底P+10(1L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1167', 3, NULL, '0-4', '飲品類 原個檸檬(10個/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2382', 10, NULL, '0-4', '飲品類 凍檸茶膽P+10(1000ML)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2346', 14, NULL, '0-4', '蔬菜類 南乳蓮藕(225G/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2226', 14, NULL, '0-4', '餡料類 野菌沙嗲牛肉餡 (2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2080', 14, NULL, '0-4', '餡料類 黑椒牛肉餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2219', 14, NULL, '0-4', '餡料類 葡汁雞餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2126', 14, NULL, '0-4', '餡料類 白汁蘑菇雞批餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2220', 14, NULL, '0-4', '餡料類 糯米糍餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2074', 14, NULL, '0-4', '餡料類 叉燒飽餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2218', 14, NULL, '0-4', '餡料類 青醬白汁雞餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2204', 90, NULL, '0-4', '醬料類 八寶醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1223', 30, NULL, '0-4', '醬料類 韓式泡菜醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2205', 30, NULL, '0-4', '醬料類 沙薑醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1121', 30, NULL, '0-4', '醬料類 漁樂馬拉盞(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2234', 14, NULL, '0-4', '醬料類 韓樂黑炸醬(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2212', 14, NULL, '0-4', '醬料類 青醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1062', 14, NULL, '0-4', '醬料類 XO醬(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2227', 60, NULL, '0-4', '醬料類 香辣腐乳醬(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1042', 14, NULL, '0-4', '醬料類 口水雞汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1181', 14, NULL, '0-4', '醬料類 韓式撈雞醬(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1018', 60, NULL, '0-4', '醬料類 沙嗲醬(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2215', 14, NULL, '0-4', '醬料類 海南雞醬(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1232', 14, NULL, '0-4', '醬料類 剁椒醬(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1222', 30, NULL, '0-4', '醬料類 拌飯醬汁(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2235', 7, NULL, '0-4', '醬料類 芝士醬(菠菜用)(1LB)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1215', 14, NULL, '0-4', '醬料類 黑松露醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2254', 60, NULL, '0-4', '醬料類 蒜蓉豆豉粒(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1093', 7, NULL, '0-4', '醬料類 肉燥拉麵醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1211', 14, NULL, '0-4', '醬料類 船麵醬(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1231', 7, NULL, '0-4', '醬料類 金銀蒜(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1237', 14, NULL, '0-4', '醬料類 蒜泥(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1202', 14, NULL, '0-4', '醬料類 薑蓉'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1044', 60, NULL, '0-4', '醬料類 漁樂沙茶醬(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2326', 60, NULL, '0-4', '醬料類 南乳醬(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2322', 120, NULL, '0-4', '醬料類 麥芽糖(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1188', 30, NULL, '0-4', '醬料類 咖喱膽(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2373', 14, NULL, '0-4', '醬料類 燒味飯汁(2KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2375', 14, NULL, '0-4', '醬料類 車仔麵醬(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2182', 14, NULL, '0-4', '雜項 熟薏米 (2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2231', 7, NULL, '0-4', '雜項 花膠(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2384', 14, NULL, '0-4', '柚皮 (300G)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2386', 7, NULL, '0-4', '飲品類 楊枝甘露(250G)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2387', 14, NULL, '0-4', '肉食類 滷水雞翼尖(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2388', 14, NULL, '0-4', '小菜類 日式蘿蔔(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2393', 7, NULL, '0-4', '醬料類 芝士醬(菠菜用)(2LB)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2395', 14, NULL, '0-4', '肉食類 法式田螺(500G)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2396', 14, NULL, '0-4', '肉食類 沙嗲牛肉(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2398', 14, NULL, '0-4', '汁水類 PP葡汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2399', 14, NULL, '0-4', '汁水類 PP蒜茸汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2400', 14, NULL, '0-4', '汁水類 PP甜酸汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2401', 14, NULL, '0-4', '汁水類 PP照燒汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2402', 14, NULL, '0-4', '汁水類 PP沙嗲牛肉汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2403', 14, NULL, '0-4', '汁水類 PP牛肝菌汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2404', 14, NULL, '0-4', '汁水類 PP黑椒汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2405', 14, NULL, '0-4', '汁水類 PP肉醬汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2406', 7, NULL, '0-4', '汁水類 PP白汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2407', 14, NULL, '0-4', '汁水類 PP鮮茄膽(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2408', 14, NULL, '0-4', '汁水類 PP龍蝦湯膽(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2409', 14, NULL, '0-4', '汁水類 PP焗飯汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2410', 14, NULL, '0-4', '汁水類 PP雜菜湯(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2411', 14, NULL, '0-4', '汁水類 PP碗仔翅(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2412', 14, NULL, '0-4', '小菜類 PP酸甜蘿蔔(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2415', 14, NULL, '0-4', '油類 PP龍蝦油(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP0261', 4, NULL, '0-4', '飲品類 青檸汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2416', 7, NULL, '0-4', '飲品類 芒果汁底(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2422', 14, NULL, '0-4', '肉食類 泡椒雞翼尖(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2229', 7, NULL, '0-4', '小菜類 特級泡菜(2磅/包)(P+7)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1131', 7, NULL, '0-4', '汁水類 牛肝菌汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2249', 3, NULL, '0-4', '肉食類 醃西冷牛扒(10oz/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1148', 7, NULL, '0-4', '粉麵﹑飯類 皮蛋瘦肉粥(500g/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1091', 7, NULL, '0-4', '湯類 龍蝦湯膽'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2068', 7, NULL, '0-4', '湯類 碗仔翅'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2113', 7, NULL, '0-4', '醬料類 魚香肉醬(1人份量/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1217', 7, NULL, '0-4', '醬料類 奶油芝士醬')
ON DUPLICATE KEY UPDATE
`defaultDays` = VALUES(`defaultDays`),
`openedDays` = VALUES(`openedDays`),
`storageC` = VALUES(`storageC`),
`remarks` = VALUES(`remarks`),
`modified` = NOW(),
`modifiedBy` = 'system',
`deleted` = 0;

-- skipped -18-only item codes (not used for print yet):
-- PP1076, PP1069, PP1078, PP2252, PP1041, PP1066, PP1082, PP1157, PP1067, PP1074, PP1149, PP2306
-- PP1210, PP2342, PP2349, PP2169, PP2242, PP2301, PP2269, PP2106, PP2292, PP2267, PP2144, PP2024
-- PP2345, PP1065, MF0419, MB0857, MF0514, PP2251, PP2265, PP2260, PP1220, PP2335, PP2279, PP2278
-- PP2341, PP2289, PP2368, PP2286, PP1071, PP2001, PP2061, PP2273, PP2272, PP2145, PP3002, MF0524
-- MF0530, MF0539, MF0551, MG1665, PP0221, PP2347, PP2348

+ 8
- 0
src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql 查看文件

@@ -0,0 +1,8 @@
--liquibase formatted sql

--changeset fpsms:item_default_shelf_life_minus18_flag
--comment: Per-item flag useMinus18: 0 = print uses defaultDays (chilled), 1 = print uses minus18Days
ALTER TABLE `item_default_shelf_life`
MODIFY COLUMN `defaultDays` INT NULL COMMENT 'Non -18 warehouse shelf life days',
ADD COLUMN `minus18Days` INT NULL COMMENT '-18 warehouse shelf life days' AFTER `defaultDays`,
ADD COLUMN `useMinus18` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = expiry uses minus18Days; 0 = uses defaultDays' AFTER `minus18Days`;

+ 220
- 0
src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql 查看文件

@@ -0,0 +1,220 @@
--liquibase formatted sql

--changeset fpsms:seed_item_default_shelf_life_minus18_flag
--comment: Upsert joExpiry.xlsx sheet XXXXXXXX: defaultDays + minus18Days + useMinus18 flag
-- generated 2026-08-20; rows=201 useMinus18=1 (frozen-only default)=43
INSERT INTO `item_default_shelf_life`
(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`,
`itemCode`, `defaultDays`, `minus18Days`, `useMinus18`, `openedDays`, `storageC`, `remarks`)
VALUES
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2240', 60, NULL, 0, NULL, '0-4', '小菜類 TOA韓式泡菜'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2241', 60, NULL, 0, NULL, '0-4', '小菜類 TOA酸甜蘿蔔'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2327', 30, NULL, 0, NULL, '0-4', '小菜類 OEM香辣炒菜莆(1KG/包)(加工)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2253', 14, 60, 0, NULL, '0-4', '小菜類 涼拌雙絲(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2244', 14, 60, 0, NULL, '0-4', '小菜類 酸甜蘿蔔粒(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2298', 14, 60, 0, NULL, '0-4', '小菜類 炒筍絲(200G)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2018', 14, 60, 0, NULL, '0-4', '小菜類 炸菜肉絲(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2008', 14, 60, 0, NULL, '0-4', '小菜類 雪菜肉絲(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2058', 14, 60, 0, NULL, '0-4', '小菜類 (熟)大冬菇'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2364', 14, 60, 0, NULL, '0-4', '小菜類 炒雪菜(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2376', 14, 60, 0, NULL, '0-4', '小菜類 切冬菇粒(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2383', 14, 60, 0, NULL, '0-4', '小菜類 辣椒菜脯(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1076', NULL, 150, 1, NULL, '-18', '汁水類 OEM照燒汁(10包/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1069', NULL, 150, 1, NULL, '-18', '汁水類 OEM甜酸汁(糖醋汁)(10包/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1078', NULL, 150, 1, NULL, '-18', '汁水類 OEM沙嗲牛肉汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2252', NULL, 150, 1, NULL, '-18', '汁水類 OEM牛肝菌汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1041', NULL, 150, 1, NULL, '-18', '汁水類 OEM葡汁(10包/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1066', NULL, 150, 1, NULL, '-18', '汁水類 OEM蒜茸汁(10包/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1082', NULL, 150, 1, NULL, '-18', '汁水類 OEM鮮茄膽(12包/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1157', NULL, 150, 1, NULL, '-18', '汁水類 OEM焗飯汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1067', NULL, 150, 1, NULL, '-18', '汁水類 OEM黑椒汁(12包/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1074', NULL, 150, 1, NULL, '-18', '汁水類 OEM白汁(12包/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1149', NULL, 30, 1, NULL, '-18', '汁水類 泰式鳳爪汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1152', 30, 60, 0, NULL, '0-4', '汁水類 魚露味水(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2274', 14, 60, 0, NULL, '0-4', '汁水類 西檸汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2284', 14, 60, 0, NULL, '0-4', '汁水類 油醋汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2306', NULL, 150, 1, NULL, '-18', '汁水類 蟹黃汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1225', 14, 60, 0, NULL, '0-4', '汁水類 香辣薑汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1178', 30, 60, 0, NULL, '0-4', '汁水類 撈麵豉油(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1043', 14, 60, 0, NULL, '0-4', '汁水類 豆豉汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2206', 14, 60, 0, NULL, '0-4', '汁水類 鮮沙薑汁(雞廠)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1126', 14, 60, 0, NULL, '0-4', '汁水類 石頭鍋汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1213', 30, 60, 0, NULL, '0-4', '汁水類 石頭飯辣汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1137', 14, 60, 0, NULL, '0-4', '汁水類 咖喱魚蛋汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1117', 14, 60, 0, NULL, '0-4', '汁水類 漁樂紅咖哩汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2262', 7, 60, 0, NULL, '0-4', '汁水類 菇奶(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2250', 30, 60, 0, NULL, '0-4', '汁水類 欖角汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1214', 30, 60, 0, NULL, '0-4', '汁水類 炒粉絲汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1209', 30, 60, 0, NULL, '0-4', '汁水類 扒飯豉油'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2243', 7, 60, 0, NULL, '0-4', '汁水類 粟米汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2210', 7, 60, 0, NULL, '0-4', '汁水類 牛奶薑汁酒'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2214', 30, 60, 0, NULL, '0-4', '汁水類 丼飯汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1224', 14, 60, 0, NULL, '0-4', '汁水類 柚子蒜蓉汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1216', 14, 60, 0, NULL, '0-4', '汁水類 叉燒蜜汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1210', NULL, 60, 1, NULL, '-18', '汁水類 泰式剌身汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1144', 7, 60, 0, NULL, '0-4', '汁水類 越式汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1080', 14, 60, 0, NULL, '0-4', '汁水類 咖哩汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2211', 14, 60, 0, NULL, '0-4', '汁水類 瑤柱鮑汁 (蠔油汁)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1234', 14, 60, 0, NULL, '0-4', '汁水類 日式咖哩汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2317', 14, 60, 0, NULL, '0-4', '汁水類 漁樂黃咖哩汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2342', NULL, 60, 1, NULL, '-18', '汁水類 UURO龍蝦膽(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2330', 60, 60, 0, NULL, '0-4', '汁水類 浸雞水(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2360', 14, 60, 0, NULL, '0-4', '汁水類 瑞士汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2333', 14, 60, 0, NULL, '0-4', '汁水類 豬鞍汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2349', NULL, 150, 1, NULL, '-18', '汁水類 芋絲汁(200G/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2331', 14, 60, 0, NULL, '0-4', '汁水類 青咖哩(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2381', 14, 60, 0, NULL, '0-4', '汁水類 咖哩豬皮(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2169', NULL, 150, 1, NULL, '-18', '肉食類 OEM滷肉(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2242', NULL, 150, 1, NULL, '-18', '肉食類 TOA梅菜扣肉'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2301', NULL, 150, 1, NULL, '-18', '肉食類 OEM法式羊架(300G小包)(4小包/袋)(8袋/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2088', 14, 60, 0, NULL, '0-4', '肉食類 淨牛腩粒(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2271', 90, 60, 0, NULL, '0-4', '肉食類 豬腳薑(350g/包) - 台嵐款'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2290', 14, 60, 0, NULL, '0-4', '肉食類 豬軟骨(400G/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2269', NULL, 30, 1, NULL, '-18', '肉食類 南乳豬手(1人份量/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2106', NULL, 30, 1, NULL, '-18', '肉食類 東坡肉(1人份/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2292', NULL, 30, 1, NULL, '-18', '肉食類 五香牛雜(180G/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2017', 14, 60, 0, NULL, '0-4', '肉食類 咸瘦肉絲'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2098', 60, NULL, 0, NULL, '0-4', '肉食類 臘腸粒(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2127', 14, 60, 0, NULL, '0-4', '肉食類 五香肉丁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2105', 14, 60, 0, NULL, '0-4', '肉食類 咖喱牛腩(1人份量/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2267', NULL, 30, 1, NULL, '-18', '肉食類 法式牛肋條 (320g/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2037', 14, 60, 0, NULL, '0-4', '肉食類 柱侯牛筋腩(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2115', 14, 60, 0, NULL, '0-4', '肉食類 瑞士雞翼(1人份量/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2193', 14, 60, 0, NULL, '0-4', '肉食類 原條牛奶叉燒'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2063', 14, 60, 0, NULL, '0-4', '肉食類 咖哩牛腩(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2167', 14, 60, 0, NULL, '0-4', '肉食類 牛丼'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2144', NULL, 30, 1, NULL, '-18', '肉食類 泰式鳳爪(1.5磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2024', NULL, 60, 1, NULL, '-18', '肉食類 豬肚'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2334', 14, 60, 0, NULL, '0-4', '肉食類 原條牛奶叉燒(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2345', NULL, 60, 1, NULL, '-18', '肉食類 一品煲(380G/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2366', 14, 60, 0, NULL, '0-4', '肉食類 清湯牛腩'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2378', 14, 60, 0, NULL, '0-4', '肉食類 醬香鳳爪(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2325', 7, 60, 0, NULL, '0-4', '沙律 薯仔蛋沙律(2.2KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1065', NULL, 150, 1, NULL, '-18', '油類 OEM龍蝦油'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2300', 60, 60, 0, NULL, '0-4', '油類 凱撒牛油(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1088', 14, 60, 0, NULL, '0-4', '油類 海南雞油(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1118', 14, 60, 0, NULL, '0-4', '油類 漁樂煉豬油'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1185', 14, 60, 0, NULL, '0-4', '油類 蒜香牛油'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1120', 14, 60, 0, NULL, '0-4', '油類 漁樂蝦頭油(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'MF0419', NULL, 60, 1, NULL, '-18', '粉麵﹑飯類 白飯(24個/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'MB0857', NULL, 60, 1, NULL, '-18', '粉麵﹑飯類 TOA蛋炒飯 (36個/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'MF0514', NULL, 150, 1, NULL, '-18', '粉麵﹑飯類 TOA 海南雞油飯(280g/個 24個/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2277', 3, 60, 0, NULL, '0-4', '粉麵﹑飯類 烚意粉(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2276', 3, 60, 0, NULL, '0-4', '粉麵﹑飯類 烚通粉(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1136', 14, 60, 0, NULL, '0-4', '粉麵﹑飯類 白粥'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1133', 14, 60, 0, NULL, '0-4', '粉麵﹑飯類 豬骨粥(1位份量)(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2363', 14, 60, 0, NULL, '0-4', '粉麵﹑飯類 炒麵底(170G/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2251', NULL, 150, 1, NULL, '-18', '湯類 OEM龍蝦湯膽(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2265', NULL, 150, 1, NULL, '-18', '湯類 OEM中式例湯(350g/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2260', NULL, 150, 1, NULL, '-18', '湯類 OEM碗仔翅(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1220', NULL, 150, 1, NULL, '-18', '湯類 OEM意大利菜湯'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2335', NULL, 150, 1, NULL, '-18', '湯類 OEM雜菜湯(12包/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2228', 60, 60, 0, NULL, '0-4', '湯類 香濃沙嗲湯膽(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1180', 7, 60, 0, NULL, '0-4', '湯類 菇湯(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2279', NULL, 30, 1, NULL, '-18', '湯類 茶樹菇排骨湯(170G/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2278', NULL, 30, 1, NULL, '-18', '湯類 胡椒豬肚雞湯(655G/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2246', 14, 60, 0, NULL, '0-4', '湯類 酸辣湯(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2282', 14, 60, 0, NULL, '0-4', '湯類 冷麵湯(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2248', 7, 60, 0, NULL, '0-4', '湯類 無添加魚湯(400ml/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2328', 7, 60, 0, NULL, '0-4', '湯類 鹿茸菇湯(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2341', NULL, 60, 1, NULL, '-18', '湯類 日式雞湯(2KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2332', 14, 60, 0, NULL, '0-4', '湯類 雞絲碗仔翅(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2329', 14, 60, 0, NULL, '0-4', '湯類 茶樹菇排骨湯(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2288', 3, NULL, 0, NULL, '0-4', '飲品類 香水檸檬汁P+3(0.8L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1175', 4, NULL, 0, NULL, '0-4', '飲品類 鮮檸檬汁(P+4)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2237', 10, NULL, 0, NULL, '0-4', '飲品類 (樽裝用)凍咖啡底P+10(0.9L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2238', 10, NULL, 0, NULL, '0-4', '飲品類 (餐廳用)凍咖啡底P+10(0.9L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2236', 10, NULL, 0, NULL, '0-4', '飲品類 (樽裝用)凍奶茶底P+10(1L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2239', 10, NULL, 0, NULL, '0-4', '飲品類 (餐廳用)凍奶茶底P+10(1L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2336', 10, NULL, 0, NULL, '0-4', '飲品類 (無糖)凍咖啡底P+10(0.9L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2338', 10, NULL, 0, NULL, '0-4', '飲品類 (無糖)凍奶茶底P+10(1L/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1167', 3, NULL, 0, NULL, '0-4', '飲品類 原個檸檬(10個/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2368', NULL, 150, 1, NULL, '-18', '飲品類 冷壓菠蘿汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2382', 10, NULL, 0, NULL, '0-4', '飲品類 凍檸茶膽P+10(1000ML)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2346', 14, 60, 0, NULL, '0-4', '蔬菜類 南乳蓮藕(225G/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2226', 14, 60, 0, NULL, '0-4', '餡料類 野菌沙嗲牛肉餡 (2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2080', 14, 60, 0, NULL, '0-4', '餡料類 黑椒牛肉餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2219', 14, 60, 0, NULL, '0-4', '餡料類 葡汁雞餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2126', 14, 60, 0, NULL, '0-4', '餡料類 白汁蘑菇雞批餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2220', 14, 60, 0, NULL, '0-4', '餡料類 糯米糍餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2074', 14, 60, 0, NULL, '0-4', '餡料類 叉燒飽餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2218', 14, 60, 0, NULL, '0-4', '餡料類 青醬白汁雞餡'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2286', NULL, 150, 1, NULL, '-18', '醬料類 OEM黑松露醬(500G)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1071', NULL, 150, 1, NULL, '-18', '醬料類 OEM肉醬汁(12包/箱)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2001', NULL, 150, 1, NULL, '-18', '醬料類 金沙咸蛋黃(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2204', 90, 60, 0, NULL, '0-4', '醬料類 八寶醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1223', 30, 60, 0, NULL, '0-4', '醬料類 韓式泡菜醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2205', 30, 60, 0, NULL, '0-4', '醬料類 沙薑醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1121', 30, 60, 0, NULL, '0-4', '醬料類 漁樂馬拉盞(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2234', 14, 60, 0, NULL, '0-4', '醬料類 韓樂黑炸醬(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2212', 14, 60, 0, NULL, '0-4', '醬料類 青醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1062', 14, 60, 0, NULL, '0-4', '醬料類 XO醬(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2227', 60, 60, 0, NULL, '0-4', '醬料類 香辣腐乳醬(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1042', 14, 60, 0, NULL, '0-4', '醬料類 口水雞汁(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1181', 14, 60, 0, NULL, '0-4', '醬料類 韓式撈雞醬(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1018', 60, 60, 0, NULL, '0-4', '醬料類 沙嗲醬(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2215', 14, 60, 0, NULL, '0-4', '醬料類 海南雞醬(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1232', 14, 60, 0, NULL, '0-4', '醬料類 剁椒醬(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1222', 30, 60, 0, NULL, '0-4', '醬料類 拌飯醬汁(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2235', 7, 60, 0, NULL, '0-4', '醬料類 芝士醬(菠菜用)(1LB)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1215', 14, 60, 0, NULL, '0-4', '醬料類 黑松露醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2254', 60, 60, 0, NULL, '0-4', '醬料類 蒜蓉豆豉粒(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1093', 7, 60, 0, NULL, '0-4', '醬料類 肉燥拉麵醬'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1211', 14, 60, 0, NULL, '0-4', '醬料類 船麵醬(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1231', 7, 60, 0, NULL, '0-4', '醬料類 金銀蒜(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1237', 14, 60, 0, NULL, '0-4', '醬料類 蒜泥(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1202', 14, 60, 0, NULL, '0-4', '醬料類 薑蓉'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1044', 60, 60, 0, NULL, '0-4', '醬料類 漁樂沙茶醬(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2326', 60, 60, 0, NULL, '0-4', '醬料類 南乳醬(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2322', 120, NULL, 0, NULL, '0-4', '醬料類 麥芽糖(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1188', 30, NULL, 0, NULL, '0-4', '醬料類 咖喱膽(2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2373', 14, 60, 0, NULL, '0-4', '醬料類 燒味飯汁(2KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2375', 14, 60, 0, NULL, '0-4', '醬料類 車仔麵醬(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2182', 14, NULL, 0, NULL, '0-4', '雜項 熟薏米 (2磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2231', 7, NULL, 0, NULL, '0-4', '雜項 花膠(1磅/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2384', 14, NULL, 0, NULL, '0-4', '柚皮 (300G)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2386', 7, NULL, 0, NULL, '0-4', '飲品類 楊枝甘露(250G)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2387', 14, 60, 0, NULL, '0-4', '肉食類 滷水雞翼尖(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2388', 14, 60, 0, NULL, '0-4', '小菜類 日式蘿蔔(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2393', 7, 60, 0, NULL, '0-4', '醬料類 芝士醬(菠菜用)(2LB)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2395', 14, 60, 0, NULL, '0-4', '肉食類 法式田螺(500G)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2396', 14, 60, 0, NULL, '0-4', '肉食類 沙嗲牛肉(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2398', 14, 60, 0, NULL, '0-4', '汁水類 PP葡汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2399', 14, 60, 0, NULL, '0-4', '汁水類 PP蒜茸汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2400', 14, 60, 0, NULL, '0-4', '汁水類 PP甜酸汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2401', 14, 60, 0, NULL, '0-4', '汁水類 PP照燒汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2402', 14, 60, 0, NULL, '0-4', '汁水類 PP沙嗲牛肉汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2403', 14, 60, 0, NULL, '0-4', '汁水類 PP牛肝菌汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2404', 14, 60, 0, NULL, '0-4', '汁水類 PP黑椒汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2405', 14, 60, 0, NULL, '0-4', '汁水類 PP肉醬汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2406', 7, 60, 0, NULL, '0-4', '汁水類 PP白汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2407', 14, 60, 0, NULL, '0-4', '汁水類 PP鮮茄膽(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2408', 14, 60, 0, NULL, '0-4', '汁水類 PP龍蝦湯膽(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2409', 14, 60, 0, NULL, '0-4', '汁水類 PP焗飯汁(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2410', 14, 60, 0, NULL, '0-4', '汁水類 PP雜菜湯(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2411', 14, 60, 0, NULL, '0-4', '汁水類 PP碗仔翅(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2412', 14, 60, 0, NULL, '0-4', '小菜類 PP酸甜蘿蔔(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2415', 14, 60, 0, NULL, '0-4', '油類 PP龍蝦油(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP0261', 4, NULL, 0, NULL, '0-4', '飲品類 青檸汁(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2416', 7, NULL, 0, NULL, '0-4', '飲品類 芒果汁底(1KG/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2422', 14, NULL, 0, NULL, '0-4', '肉食類 泡椒雞翼尖(1KG)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2229', 7, NULL, 0, NULL, '0-4', '小菜類 特級泡菜(2磅/包)(P+7)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1131', 7, NULL, 0, NULL, '0-4', '汁水類 牛肝菌汁'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2145', NULL, 30, 1, NULL, '-18', '肉食類 炆羊腩(1人份量/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2249', 3, NULL, 0, NULL, '0-4', '肉食類 醃西冷牛扒(10oz/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1148', 7, NULL, 0, NULL, '0-4', '粉麵﹑飯類 皮蛋瘦肉粥(500g/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP3002', NULL, 60, 1, NULL, '-18', '湯類 蓮藕湯(1人份/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1091', 7, NULL, 0, NULL, '0-4', '湯類 龍蝦湯膽'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2068', 7, NULL, 0, NULL, '0-4', '湯類 碗仔翅'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2113', 7, NULL, 0, NULL, '0-4', '醬料類 魚香肉醬(1人份量/包)'),
(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1217', 7, NULL, 0, NULL, '0-4', '醬料類 奶油芝士醬')
ON DUPLICATE KEY UPDATE
`defaultDays` = VALUES(`defaultDays`),
`minus18Days` = VALUES(`minus18Days`),
`useMinus18` = VALUES(`useMinus18`),
`openedDays` = VALUES(`openedDays`),
`storageC` = VALUES(`storageC`),
`remarks` = VALUES(`remarks`),
`modified` = NOW(),
`modifiedBy` = 'system',
`deleted` = 0;

+ 21
- 0
src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql 查看文件

@@ -0,0 +1,21 @@
--liquibase formatted sql

--changeset fpsms:create_onpack_template_file
--comment: User-maintained OnPack template files (汁水機 / 檸檬機). ZIP load prefers these over classpath.
CREATE TABLE `onpack_template_file`
(
`id` INT 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',
`machine` VARCHAR(20) NOT NULL COMMENT 'juice=onpack2030 汁水機; lemon=onpack2030_2 檸檬機',
`itemCode` VARCHAR(50) NOT NULL,
`fileName` VARCHAR(200) NOT NULL,
`byteSize` INT NOT NULL,
`fileBytes` MEDIUMBLOB NOT NULL,
CONSTRAINT pk_onpack_template_file PRIMARY KEY (`id`),
UNIQUE KEY uk_onpack_template_file (`machine`, `itemCode`, `fileName`)
);

二進制
src/main/resources/onpack2030_exp/pp1181.image 查看文件


+ 38
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt 查看文件

@@ -0,0 +1,38 @@
package com.ffii.fpsms.modules.jobOrder.service

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class LaserBag2PayloadTest {

@Test
fun payload_without_expiry_keeps_original_three_fields() {
assertEquals(
"{\"itemId\":1,\"stockInLineId\":2};PP1175;鮮檸檬汁;;",
PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "PP1175", "鮮檸檬汁", null),
)
assertEquals(
"0;PP1175;鮮檸檬汁;;",
PlasticBagPrinterService.buildLaserBag2Payload(null, null, "PP1175", "鮮檸檬汁"),
)
}

@Test
fun payload_with_iso_expiry_adds_fourth_print_label_field() {
assertEquals(
"{\"itemId\":1,\"stockInLineId\":2};PP1175;鮮檸檬汁;Expiry Date 20260821;;",
PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "PP1175", "鮮檸檬汁", "2026-08-21"),
)
}

@Test
fun formatLaserExpiryParam_accepts_iso_compact_and_print_label() {
assertEquals("Expiry Date 20260821", PlasticBagPrinterService.formatLaserExpiryParam("2026-08-21"))
assertEquals("Expiry Date 20260821", PlasticBagPrinterService.formatLaserExpiryParam("20260821"))
assertEquals(
"Expiry Date 20260821",
PlasticBagPrinterService.formatLaserExpiryParam("Expiry Date 20260821"),
)
assertEquals("", PlasticBagPrinterService.formatLaserExpiryParam(" "))
}
}

+ 109
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt 查看文件

@@ -0,0 +1,109 @@
package com.ffii.fpsms.modules.jobOrder.service

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class OnPackJuiceExpiryXmlTest {

private val sample = """
<Legend type='Image.V1'><Orientation>DEG_0</Orientation><Width>5300</Width><Height>7100</Height><FieldList>
<Logo type='Logo.V1'><Name>LOGO_3</Name><ID>6</ID><Geometry><X>500</X><Y>2500</Y><Rotation>0</Rotation></Geometry><FieldColor>BLACK</FieldColor><FileName>date.bmp</FileName><Width>4203</Width><Height>1173</Height></Logo>
<Logo type='Logo.V1'><Name>LOGO_4</Name><ID>7</ID><Geometry><X>750</X><Y>3750</Y><Rotation>0</Rotation></Geometry><FieldColor>BLACK</FieldColor><FileName>pp1080qr.bmp</FileName><Width>3913</Width><Height>3000</Height></Logo>
</FieldList></Legend>
""".trimIndent()

@Test
fun rewriteLogoFileName_updates_logo_not_logo2() {
val xml = """
<FieldList>
<Logo><Name>LOGO</Name><FileName>old-product.bmp</FileName></Logo>
<Logo><Name>LOGO_2</Name><FileName>old-code.bmp</FileName></Logo>
</FieldList>
""".trimIndent()
val out = OnPackJuiceExpiryXml.rewriteLogoFileName(xml, "LOGO", "pp1181Product.bmp")
val out2 = OnPackJuiceExpiryXml.rewriteLogoFileName(out, "LOGO_2", "pp1181Code.bmp")
assertTrue(out2.contains("<Name>LOGO</Name><FileName>pp1181Product.bmp</FileName>"))
assertTrue(out2.contains("<Name>LOGO_2</Name><FileName>pp1181Code.bmp</FileName>"))
assertFalse(out2.contains("old-product.bmp"))
assertFalse(out2.contains("old-code.bmp"))
}

@Test
fun applyExpiry_places_expiry_above_moved_qr() {
val out = OnPackJuiceExpiryXml.applyExpiry(sample, "pp1080exp.bmp", 1800)
val layout = OnPackJuiceExpiryXml.layoutFor(sample, 1800)
assertTrue(out.contains("<Name>LOGO_EXP</Name>"))
assertTrue(out.contains("<FileName>pp1080exp.bmp</FileName>"))
assertTrue(out.contains("<FileName>date.bmp</FileName>"))
assertTrue(out.contains("<FileName>pp1080qr.bmp</FileName>"))
assertTrue(out.contains("<Height>${layout.dateHeight}</Height>"))
assertTrue(out.contains("<Y>${layout.expY}</Y>"))
assertTrue(out.contains("<Y>${layout.qrY}</Y>"))
assertTrue(layout.expY + layout.expHeight <= layout.qrY)
assertEquals(1, Regex("<Name>LOGO_3</Name>").findAll(out).count())
val expIdx = out.indexOf("<Name>LOGO_EXP</Name>")
val qrIdx = out.indexOf("<Name>LOGO_4</Name>")
assertTrue(expIdx in 1 until qrIdx)
}

@Test
fun applyExpiry_replaces_existing_logo_exp_filename_and_geometry() {
val once = OnPackJuiceExpiryXml.applyExpiry(sample, "pp1080exp.bmp", 1800)
val twice = OnPackJuiceExpiryXml.applyExpiry(once, "otherexp.bmp", 1800)
assertEquals(1, Regex("<Name>LOGO_EXP</Name>").findAll(twice).count())
assertTrue(twice.contains("<FileName>otherexp.bmp</FileName>"))
assertFalse(twice.contains("<FileName>pp1080exp.bmp</FileName>"))
assertTrue(twice.contains("<FileName>date.bmp</FileName>"))
}

@Test
fun applyExpiry_rewrites_logo5_filename_without_injecting_logo_exp() {
val designed = """
<Legend type='Image.V1'><Orientation>DEG_0</Orientation><Width>5300</Width><Height>7100</Height><FieldList>
<Logo type='Logo.V1'><Name>LOGO_3</Name><ID>8</ID><Geometry><X>500</X><Y>2000</Y><Rotation>0</Rotation></Geometry><FieldColor>BLACK</FieldColor><FileName>date.bmp</FileName><Width>4203</Width><Height>1173</Height></Logo>
<Logo type='Logo.V1'><Name>LOGO_5</Name><ID>10</ID><Geometry><X>0</X><Y>3000</Y><Rotation>0</Rotation></Geometry><FieldColor>BLACK</FieldColor><FileName>old-exp.bmp</FileName><Width>5187</Width><Height>657</Height></Logo>
<Logo type='Logo.V1'><Name>LOGO_4</Name><ID>9</ID><Geometry><X>250</X><Y>4000</Y><Rotation>0</Rotation></Geometry><FieldColor>BLACK</FieldColor><FileName>pp1181qr.bmp</FileName><Width>4836</Width><Height>2500</Height></Logo>
</FieldList></Legend>
""".trimIndent()
val out = OnPackJuiceExpiryXml.applyExpiry(designed, "pp1181exp.bmp", 1800)
assertEquals("LOGO_5", OnPackJuiceExpiryXml.existingExpirySlotName(designed))
assertFalse(out.contains("<Name>LOGO_EXP</Name>"))
assertEquals(1, Regex("<Name>LOGO_5</Name>").findAll(out).count())
assertTrue(out.contains("<FileName>pp1181exp.bmp</FileName>"))
assertFalse(out.contains("<FileName>old-exp.bmp</FileName>"))
assertTrue(out.contains("<Y>3000</Y>"))
assertTrue(out.contains("<Y>4000</Y>"))
assertTrue(out.contains("<Height>1173</Height>"))
assertTrue(out.contains("<FileName>date.bmp</FileName>"))
}

@Test
fun applyExpiry_inserts_pp2211exp_above_qr_on_real_template() {
val bytes = javaClass.classLoader.getResourceAsStream("onpack2030/pp2211.image")!!.use { it.readBytes() }
val xml = String(bytes.copyOfRange(2, bytes.size), Charsets.UTF_16LE)
val out = OnPackJuiceExpiryXml.applyExpiry(xml, "pp2211exp.bmp", 1800)
val layout = OnPackJuiceExpiryXml.layoutFor(xml, 1800)
assertTrue(out.contains("<Name>LOGO_EXP</Name>"))
assertTrue(out.contains("<FileName>pp2211exp.bmp</FileName>"))
assertTrue(out.contains("pp2211qr.bmp"))
assertTrue(layout.expY + layout.expHeight + 200 <= layout.qrY)
assertTrue(layout.qrY > 3750)
}

@Test
fun applyExpiry_uses_real_pp1181_exp_template_slot() {
val bytes = javaClass.classLoader.getResourceAsStream("onpack2030_exp/pp1181.image")!!.use { it.readBytes() }
val xml = String(bytes.copyOfRange(2, bytes.size), Charsets.UTF_16LE)
val out = OnPackJuiceExpiryXml.applyExpiry(xml, "pp1181exp.bmp", 1800)
assertEquals("LOGO_5", OnPackJuiceExpiryXml.existingExpirySlotName(xml))
assertFalse(out.contains("<Name>LOGO_EXP</Name>"))
assertTrue(out.contains("<Name>LOGO_5</Name>"))
assertTrue(out.contains("<FileName>pp1181exp.bmp</FileName>"))
val y4 = Regex("""(?s)<Name>\s*LOGO_4\s*</Name>[\s\S]*?<Y>\s*(\d+)\s*</Y>""").find(out)?.groupValues?.get(1)
assertEquals("4000", y4)
val y5 = Regex("""(?s)<Name>\s*LOGO_5\s*</Name>[\s\S]*?<Y>\s*(\d+)\s*</Y>""").find(out)?.groupValues?.get(1)
assertEquals("3000", y5)
}
}

+ 40
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt 查看文件

@@ -0,0 +1,40 @@
package com.ffii.fpsms.modules.jobOrder.service

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class OnPackLemonExpiryXmlTest {

private val sample = """
<Legend><FieldList>
<AlphaNumeric type='AlphaNum.V1'><Name>TEXT_2</Name><ID>3</ID><Geometry><X>2900</X><Y>2250</Y><Rotation>90</Rotation></Geometry>
<Text type='TextBased.V1'><Font type='Font.V1'><PointSizeInHM>783</PointSizeInHM></Font></Text>
<Static type='StaticSrc.V1'><Text>PP1175-21</Text></Static></AlphaNumeric>
<AlphaNumeric type='AlphaNum.V1'><Name>TEXT_3</Name><ID>4</ID><Geometry><X>2000</X><Y>2250</Y><Rotation>90</Rotation></Geometry>
<TimeDate type='TimeDateSrc.V1'><OffsetDays>0</OffsetDays></TimeDate></AlphaNumeric>
</FieldList></Legend>
""".trimIndent()

@Test
fun applyExpiry_inserts_text_exp_between_lot_and_production_date() {
val out = OnPackLemonExpiryXml.applyExpiry(sample, "Expiry Date : 20/8/2027")
assertTrue(out.contains("<Name>TEXT_EXP</Name>"))
assertTrue(out.contains("<Text>Expiry Date : 20/8/2027</Text>"))
assertTrue(out.contains("<Name>TEXT_3</Name>"))
assertTrue(out.contains("<OffsetDays>0</OffsetDays>"))
assertTrue(out.contains("<X>2450</X>"))
assertTrue(out.contains("<PointSizeInHM>520</PointSizeInHM>"))
assertFalse(out.contains("<PointSizeInHM>783</PointSizeInHM>"))
}

@Test
fun applyExpiry_replaces_existing_text_exp() {
val once = OnPackLemonExpiryXml.applyExpiry(sample, "Expiry Date : 20/8/2027")
val twice = OnPackLemonExpiryXml.applyExpiry(once, "Expiry Date : 1/1/2028")
assertEquals(1, Regex("<Name>TEXT_EXP</Name>").findAll(twice).count())
assertTrue(twice.contains("<Text>Expiry Date : 1/1/2028</Text>"))
assertFalse(twice.contains("<Text>Expiry Date : 20/8/2027</Text>"))
}
}

+ 87
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt 查看文件

@@ -0,0 +1,87 @@
package com.ffii.fpsms.modules.jobOrder.service

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class OnPackTemplateFileServiceTest {

@Test
fun storedFileName_normalizes_image_by_machine() {
assertEquals(
"pp2211.image",
OnPackTemplateFileService.storedFileName("juice", "PP2211", "PP2211.image"),
)
assertEquals(
"PP1175.image",
OnPackTemplateFileService.storedFileName("lemon", "pp1175", "foo.image"),
)
assertEquals(
"product.bmp",
OnPackTemplateFileService.storedFileName("juice", "PP2211", "sub/product.bmp"),
)
}

@Test
fun sanitizeFileName_rejects_path_and_bad_ext() {
assertNull(OnPackTemplateFileService.sanitizeFileName("../secret.txt"))
assertNull(OnPackTemplateFileService.sanitizeFileName("x.exe"))
assertEquals("a.job", OnPackTemplateFileService.sanitizeFileName("a.job"))
}

@Test
fun normalizeMachine_accepts_aliases() {
assertEquals("juice", OnPackTemplateFileService.normalizeMachine("汁水機"))
assertEquals("lemon", OnPackTemplateFileService.normalizeMachine("onpack2030_2"))
assertThrows<IllegalArgumentException> { OnPackTemplateFileService.normalizeMachine("laser") }
}

@Test
fun imageFileNameCandidates_cover_case() {
val juice = OnPackTemplateFileService.imageFileNameCandidates("juice", "PP2211")
assertTrue(juice.contains("pp2211.image"))
val lemon = OnPackTemplateFileService.imageFileNameCandidates("lemon", "pp1175")
assertTrue(lemon.contains("PP1175.image"))
}

@Test
fun mergeSupported_marks_juice_vs_sources() {
val rows = OnPackTemplateFileService.mergeSupported(
registered = setOf("PP2211", "PP9999"),
inDatabase = setOf("PP9999"),
builtin = setOf("PP2211", "PP1080"),
)
val byCode = rows.associateBy { it.itemCode }
assertEquals(setOf("PP1080", "PP2211", "PP9999"), byCode.keys)
assertTrue(byCode.getValue("PP2211").printable && byCode.getValue("PP2211").builtin)
assertTrue(byCode.getValue("PP9999").printable && byCode.getValue("PP9999").inDatabase)
assertTrue(byCode.getValue("PP1080").printable && !byCode.getValue("PP1080").registered)
}

@Test
fun scanBuiltinImageCodes_finds_juice_and_lemon_templates() {
val juice = OnPackTemplateFileService.scanBuiltinImageCodes("juice")
val lemon = OnPackTemplateFileService.scanBuiltinImageCodes("lemon")
assertTrue(juice.contains("PP2211"), juice.toString())
assertTrue(juice.size >= 20, "juice builtin count=${juice.size}")
assertTrue(!juice.contains("TESTPP1234"), juice.toString())
assertTrue(!juice.contains("TESTPP1126"), juice.toString())
assertTrue(!juice.contains("TT_PP1167"), juice.toString())
assertTrue(!juice.contains("LO342987"), juice.toString())
assertTrue(!juice.contains("LPP2290A"), juice.toString())
assertTrue(lemon.contains("PP1175"), lemon.toString())
assertTrue(lemon.size >= 5, "lemon builtin count=${lemon.size}")
}

@Test
fun itemCodeFromImageFileName_skips_non_item_templates() {
assertEquals("PP2211", OnPackTemplateFileService.itemCodeFromImageFileName("pp2211.image"))
assertNull(OnPackTemplateFileService.itemCodeFromImageFileName("default.image"))
assertNull(OnPackTemplateFileService.itemCodeFromImageFileName("日期.image"))
assertTrue(OnPackTemplateFileService.isListedBuiltinCode("PP2211"))
assertTrue(!OnPackTemplateFileService.isListedBuiltinCode("TESTPP1234"))
assertTrue(!OnPackTemplateFileService.isListedBuiltinCode("TT_PP1167"))
}
}

+ 206
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt 查看文件

@@ -0,0 +1,206 @@
package com.ffii.fpsms.modules.jobOrder.service

import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.xml.sax.InputSource
import java.io.File
import java.io.StringReader
import javax.xml.parsers.DocumentBuilderFactory

/**
* Load-safety QA for SmartDate X40: rewritten `.image` bytes must stay well-formed,
* keep the original encoding/BOM, unique field names/IDs, and stay on the canvas.
*/
class OnPackTemplateLoadQaTest {

@Test
fun juice_templates_stay_loadable_after_expiry_rewrite() {
val failures = mutableListOf<String>()
val expiryBmpWidth = 1800
for (folder in listOf("onpack2030", "onpack2030_exp")) {
for (file in imageFiles(folder)) {
val original = file.readBytes()
val (xml, encodeBack) = OnPackImageTemplateCodec.decode(original)
val stem = file.nameWithoutExtension.lowercase()
val qrName = "${stem}qr.bmp"
val expName = "${stem}exp.bmp"
val withQr = xml.replace(
Regex("""(<Name>\s*LOGO_4\s*</Name>[\s\S]*?<FileName>)([^<]+)(</FileName>)"""),
"$1$qrName$3",
)
val rewritten = OnPackJuiceExpiryXml.applyExpiry(withQr, expName, expiryBmpWidth)
val outBytes = encodeBack(rewritten)
val (roundTrip, _) = OnPackImageTemplateCodec.decode(outBytes)
val label = "$folder/${file.name}"
checkCommon(failures, label, original, outBytes, xml, roundTrip)
val hadLogo4 = Regex("""<Name>\s*LOGO_4\s*</Name>""").containsMatchIn(xml)
val slot = OnPackJuiceExpiryXml.existingExpirySlotName(xml)
if (slot != null) {
if (slot == "LOGO_5" && Regex("""<Name>\s*LOGO_EXP\s*</Name>""").containsMatchIn(roundTrip)) {
failures.add("$label: must not inject LOGO_EXP when LOGO_5 exists")
}
val slotFile = Regex(
"""(?s)<Name>\s*${Regex.escape(slot)}\s*</Name>[\s\S]*?<FileName>([^<]+)</FileName>""",
).find(roundTrip)?.groupValues?.get(1)
if (slotFile != expName) {
failures.add("$label: $slot FileName=$slotFile expected $expName")
}
val yOrig = Regex("""(?s)<Name>\s*LOGO_4\s*</Name>[\s\S]*?<Y>\s*(\d+)\s*</Y>""").find(xml)?.groupValues?.get(1)
val yNew = Regex("""(?s)<Name>\s*LOGO_4\s*</Name>[\s\S]*?<Y>\s*(\d+)\s*</Y>""").find(roundTrip)?.groupValues?.get(1)
if (yOrig != yNew) {
failures.add("$label: designer template QR Y changed $yOrig -> $yNew")
}
continue
}
if (!hadLogo4) {
if (roundTrip.contains("LOGO_EXP")) {
failures.add("$label: template without LOGO_4 must not gain LOGO_EXP")
}
continue
}
if (!roundTrip.contains("<Name>LOGO_EXP</Name>")) {
failures.add("$label: missing LOGO_EXP")
}
if (!roundTrip.contains("<FileName>$expName</FileName>")) {
failures.add("$label: .image does not reference $expName")
}
if (!roundTrip.contains("<FileName>$qrName</FileName>")) {
failures.add("$label: LOGO_4 not pointing at $qrName")
}
if (Regex("""<Name>LOGO_EXP</Name>""").findAll(roundTrip).count() != 1) {
failures.add("$label: LOGO_EXP count != 1")
}
val twice = OnPackJuiceExpiryXml.applyExpiry(roundTrip, expName, expiryBmpWidth)
if (Regex("""<Name>LOGO_EXP</Name>""").findAll(twice).count() != 1) {
failures.add("$label: second apply duplicated LOGO_EXP")
}
val layout = OnPackJuiceExpiryXml.layoutFor(xml, expiryBmpWidth)
if (layout.expY + layout.expHeight > layout.qrY) {
failures.add("$label: expiry overlaps QR (expBottom=${layout.expY + layout.expHeight} qrY=${layout.qrY})")
}
if (layout.qrY + layout.qrHeight > layout.canvasHeight) {
failures.add("$label: QR below canvas (bottom=${layout.qrY + layout.qrHeight} canvas=${layout.canvasHeight})")
}
if (layout.x + layout.expWidth > layout.canvasWidth + 1) {
failures.add("$label: expiry wider than canvas")
}
}
}
if (failures.isNotEmpty()) {
throw AssertionError(failures.joinToString("\n"))
}
}

@Test
fun lemon_templates_stay_loadable_after_expiry_rewrite() {
val failures = mutableListOf<String>()
val label = "Expiry Date : 31/8/2026"
for (file in imageFiles("onpack2030_2")) {
val original = file.readBytes()
val (xml, encodeBack) = OnPackImageTemplateCodec.decode(original)
val rewritten = OnPackLemonExpiryXml.applyExpiry(xml, label)
val outBytes = encodeBack(rewritten)
val (roundTrip, _) = OnPackImageTemplateCodec.decode(outBytes)
checkCommon(failures, file.name, original, outBytes, xml, roundTrip)
if (!Regex("""<Name>\s*TEXT_3\s*</Name>""").containsMatchIn(xml)) {
continue
}
if (!roundTrip.contains("<Name>TEXT_EXP</Name>")) {
failures.add("${file.name}: missing TEXT_EXP")
}
if (!roundTrip.contains("<Text>$label</Text>")) {
failures.add("${file.name}: TEXT_EXP wording missing")
}
if (!roundTrip.contains("<OffsetDays>0</OffsetDays>")) {
failures.add("${file.name}: TEXT_3 production date OffsetDays changed")
}
if (Regex("""<Name>TEXT_EXP</Name>""").findAll(roundTrip).count() != 1) {
failures.add("${file.name}: TEXT_EXP count != 1")
}
val twice = OnPackLemonExpiryXml.applyExpiry(roundTrip, "Expiry Date : 1/1/2028")
if (Regex("""<Name>TEXT_EXP</Name>""").findAll(twice).count() != 1) {
failures.add("${file.name}: second apply duplicated TEXT_EXP")
}
}
if (failures.isNotEmpty()) {
throw AssertionError(failures.joinToString("\n"))
}
}

@Test
fun juice_qa_covers_production_skus() {
val names = imageFiles("onpack2030").map { it.name.lowercase() }.toSet()
assertTrue(names.contains("pp2211.image"), "pp2211.image must be in juice QA set")
assertTrue(names.contains("pp1080.image"), "pp1080.image must be in juice QA set")
val expNames = imageFiles("onpack2030_exp").map { it.name.lowercase() }.toSet()
assertTrue(expNames.contains("pp1181.image"), "pp1181.image must be in expiry layout set")
}

private fun checkCommon(
failures: MutableList<String>,
name: String,
original: ByteArray,
outBytes: ByteArray,
originalXml: String,
outXml: String,
) {
if (OnPackImageTemplateCodec.hasUtf16LeBom(original) && !OnPackImageTemplateCodec.hasUtf16LeBom(outBytes)) {
failures.add("$name: lost UTF-16 LE BOM (X40 will not load)")
}
if (OnPackImageTemplateCodec.hasUtf16LeBom(outBytes) &&
outBytes.size >= 4 &&
outBytes[2] == 0xFF.toByte() &&
outBytes[3] == 0xFE.toByte()
) {
failures.add("$name: double UTF-16 BOM")
}
if (countTag(outXml, "FieldList") != countTag(originalXml, "FieldList")) {
failures.add("$name: FieldList count changed")
}
if (!outXml.contains("</FieldList>") || !outXml.contains("<Legend")) {
failures.add("$name: missing Legend/FieldList")
}
val parseError = wellFormedError(outXml)
if (parseError != null) {
failures.add("$name: XML not well-formed: $parseError")
}
val dupNames = duplicates(Regex("""<Name>([^<]+)</Name>""").findAll(outXml).map { it.groupValues[1].trim() }.toList())
if (dupNames.isNotEmpty()) {
failures.add("$name: duplicate field names $dupNames")
}
val dupIds = duplicates(Regex("""<ID>(\d+)</ID>""").findAll(outXml).map { it.groupValues[1] }.toList())
if (dupIds.isNotEmpty()) {
failures.add("$name: duplicate field IDs $dupIds")
}
}

private fun imageFiles(folder: String): List<File> {
val probe = javaClass.classLoader.getResource(folder)
?: throw AssertionError("Missing classpath folder $folder")
val dir = File(probe.toURI())
assertTrue(dir.isDirectory, "$folder should be a directory")
return dir.listFiles { f -> f.isFile && f.name.endsWith(".image") }!!.sortedBy { it.name }
}

private fun wellFormedError(xml: String): String? {
return try {
val factory = DocumentBuilderFactory.newInstance()
factory.isNamespaceAware = false
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
val doc = factory.newDocumentBuilder().parse(InputSource(StringReader(xml)))
if (doc.documentElement.tagName != "Legend") {
"root is ${doc.documentElement.tagName}"
} else {
null
}
} catch (e: Exception) {
e.message ?: e.javaClass.simpleName
}
}

private fun countTag(xml: String, tag: String): Int =
Regex("</?$tag(?:\\s|>)").findAll(xml).count()

private fun duplicates(values: List<String>): List<String> =
values.groupingBy { it }.eachCount().filter { it.value > 1 }.keys.sorted()
}

+ 45
- 0
src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt 查看文件

@@ -0,0 +1,45 @@
package com.ffii.fpsms.modules.master.service

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.LocalDate

class ItemDefaultShelfLifeServiceTest {

@Test
fun expiryOn_adds_default_days_to_print_date() {
assertEquals(
LocalDate.of(2027, 8, 20),
ItemDefaultShelfLifeService.expiryOn(LocalDate.of(2026, 8, 20), 365),
)
assertEquals(
LocalDate.of(2026, 11, 18),
ItemDefaultShelfLifeService.expiryOn(LocalDate.of(2026, 8, 20), 90),
)
}

@Test
fun effectiveDays_follows_useMinus18_flag() {
assertEquals(14, ItemDefaultShelfLifeService.effectiveDays(14, 60, false))
assertEquals(60, ItemDefaultShelfLifeService.effectiveDays(14, 60, true))
assertEquals(150, ItemDefaultShelfLifeService.effectiveDays(null, 150, true))
assertEquals(null, ItemDefaultShelfLifeService.effectiveDays(null, null, true))
assertEquals(null, ItemDefaultShelfLifeService.effectiveDays(null, 150, false))
}

@Test
fun formatPrintLabel_uses_yyyyMMdd() {
assertEquals(
"Expiry Date 20260831",
ItemDefaultShelfLifeService.formatPrintLabel(LocalDate.of(2026, 8, 31)),
)
assertEquals(
"Expiry Date 20270101",
ItemDefaultShelfLifeService.formatPrintLabel(LocalDate.of(2027, 1, 1)),
)
assertEquals(
"20260821",
ItemDefaultShelfLifeService.formatProductionDatePrintLabel(LocalDate.of(2026, 8, 21)),
)
}
}

+ 94
- 0
src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt 查看文件

@@ -0,0 +1,94 @@
package com.ffii.fpsms.modules.master.service

import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class ItemM18IdRemapSupportTest {

@Test
fun canLink_whenM18SyncHasNewProIdAndSameCodeItem() {
assertTrue(
ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode(
requestId = null,
requestM18Id = 20022L,
duplicatedItemId = 10L,
ownerOfNewM18IdItemId = null,
)
)
}

@Test
fun canLink_whenExistingItemHasNoM18IdYet() {
assertTrue(
ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode(
requestId = null,
requestM18Id = 20022L,
duplicatedItemId = 10L,
ownerOfNewM18IdItemId = null,
)
)
}

@Test
fun cannotLink_whenAnotherItemAlreadyOwnsNewM18Id() {
assertFalse(
ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode(
requestId = null,
requestM18Id = 20022L,
duplicatedItemId = 10L,
ownerOfNewM18IdItemId = 99L,
)
)
}

@Test
fun cannotLink_whenRequestTargetsADifferentLocalItem() {
assertFalse(
ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode(
requestId = 11L,
requestM18Id = 20022L,
duplicatedItemId = 10L,
ownerOfNewM18IdItemId = null,
)
)
}

@Test
fun cannotLink_whenNoM18IdOnRequest() {
assertFalse(
ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode(
requestId = null,
requestM18Id = null,
duplicatedItemId = 10L,
ownerOfNewM18IdItemId = null,
)
)
}

@Test
fun isM18IdLinkOnly_trueAfterMissThenLink() {
assertTrue(
ItemM18IdRemapSupport.isM18IdLinkOnly(
existingByM18IdWasMissing = true,
message = ItemM18IdRemapSupport.LINKED_MESSAGE,
)
)
}

@Test
fun isM18IdLinkOnly_falseForNormalProductUpdate() {
assertFalse(
ItemM18IdRemapSupport.isM18IdLinkOnly(
existingByM18IdWasMissing = false,
message = ItemM18IdRemapSupport.LINKED_MESSAGE,
)
)
assertFalse(
ItemM18IdRemapSupport.isM18IdLinkOnly(
existingByM18IdWasMissing = true,
message = "M18 Item does not have any updates",
)
)
}
}

+ 36
- 10
src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt 查看文件

@@ -4,6 +4,8 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.LocalDate
import com.ffii.fpsms.modules.master.service.ItemPrintShelfLife

class PyJobOrderListMapperTest {

@@ -64,14 +66,38 @@ class PyJobOrderListMapperTest {
}

@Test
fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() {
assertEquals(
"鮮檸檬汁(P+4)",
PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"),
)
assertEquals(
"咖哩汁(1包X2磅)",
PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"),
)
}
fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() {
assertEquals(
"鮮檸檬汁(P+4)",
PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"),
)
assertEquals(
"咖哩汁(1包X2磅)",
PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"),
)
}

@Test
fun shelfLifeForItem_computes_expiry_from_print_date() {
val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(
"f0025",
mapOf("F0025" to ItemPrintShelfLife(365, false)),
LocalDate.of(2026, 8, 20),
)
assertEquals(365, days)
assertEquals(false, useMinus18)
assertEquals(LocalDate.of(2027, 8, 20), expiry)
}

@Test
fun shelfLifeForItem_is_null_when_item_missing() {
val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(
"PP1175",
emptyMap(),
LocalDate.of(2026, 8, 20),
)
assertEquals(null, days)
assertEquals(null, useMinus18)
assertEquals(null, expiry)
}
}

Loading…
取消
儲存