| @@ -93,6 +93,10 @@ kotlin { | |||||
| jvmToolchain(17) | jvmToolchain(17) | ||||
| } | } | ||||
| test { | |||||
| useJUnitPlatform() | |||||
| } | |||||
| bootRun { | bootRun { | ||||
| // Use db-local profile by default so datasource is loaded (application-db-local.yml). | // Use db-local profile by default so datasource is loaded (application-db-local.yml). | ||||
| // Override with: ./gradlew bootRun --args='--spring.profiles.active=prod' | // Override with: ./gradlew bootRun --args='--spring.profiles.active=prod' | ||||
| @@ -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. | |||||
| @@ -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, | |||||
| ) | |||||
| @@ -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 pyinstaller | ||||
| py -m pip install --upgrade pywin32 | 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 --- | # --- 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): | 2) Compare BUILD machines (both should match): | ||||
| py --version | py --version | ||||
| py -m PyInstaller --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: | A broken build is often caused by: | ||||
| - Different Python major version (e.g. 3.13 vs 3.11) | - 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) | 3) Rebuild on the machine that works, or reinstall Python from python.org (64-bit) | ||||
| and reinstall deps above, then rebuild. | 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). | 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. | |||||
| @@ -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() | |||||
| @@ -195,6 +195,17 @@ open class M18MasterDataService( | |||||
| return itemsService.findByM18Id(m18ItemId)?.id | 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? { | open fun saveProduct(id: Long): MessageResponse? { | ||||
| try { | try { | ||||
| ensureCunitSeededForAllIfEmpty() | ensureCunitSeededForAllIfEmpty() | ||||
| @@ -203,20 +214,14 @@ open class M18MasterDataService( | |||||
| val price = itemDetail?.data?.price | val price = itemDetail?.data?.price | ||||
| if (itemDetail != null && pro != null) { | if (itemDetail != null && pro != null) { | ||||
| val mappedType = mapM18ProductType(pro.udfProducttype) | |||||
| val existingItem = itemsService.findByM18Id(id) | val existingItem = itemsService.findByM18Id(id) | ||||
| val saveItemRequest = NewItemRequest( | val saveItemRequest = NewItemRequest( | ||||
| code = pro.code, | code = pro.code, | ||||
| name = pro.desc, | name = pro.desc, | ||||
| // type = if (pro.seriesId == m18Config.SERIESID_PF) ProductType.MATERIAL | // type = if (pro.seriesId == m18Config.SERIESID_PF) ProductType.MATERIAL | ||||
| // else ItemType.PRODUCT, | // 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, | id = existingItem?.id, | ||||
| description = pro.desc, | description = pro.desc, | ||||
| remarks = null, | remarks = null, | ||||
| @@ -247,6 +252,12 @@ open class M18MasterDataService( | |||||
| logger.error("saveItem duplicate code for M18 item $id (code=${pro.code}): ${savedItem.message}") | logger.error("saveItem duplicate code for M18 item $id (code=${pro.code}): ${savedItem.message}") | ||||
| return null | 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...") | logger.info("Processing item uom...") | ||||
| // Find the item uom that ready to delete (not in m18) | // Find the item uom that ready to delete (not in m18) | ||||
| val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | ||||
| @@ -382,6 +393,7 @@ open class M18MasterDataService( | |||||
| val price = itemDetail?.data?.price | val price = itemDetail?.data?.price | ||||
| if (itemDetail != null && pro != null) { | if (itemDetail != null && pro != null) { | ||||
| val mappedType = mapM18ProductType(pro.udfProducttype) | |||||
| // ── Use cache instead of direct call ──────────────────────── | // ── Use cache instead of direct call ──────────────────────── | ||||
| val existingItem = itemCache.getOrPut(item.id) { | val existingItem = itemCache.getOrPut(item.id) { | ||||
| itemsService.findByM18Id(item.id) | itemsService.findByM18Id(item.id) | ||||
| @@ -390,14 +402,7 @@ open class M18MasterDataService( | |||||
| val saveItemRequest = NewItemRequest( | val saveItemRequest = NewItemRequest( | ||||
| code = pro.code, | code = pro.code, | ||||
| name = pro.desc, | 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, | id = existingItem?.id, | ||||
| description = pro.desc, | description = pro.desc, | ||||
| remarks = null, | remarks = null, | ||||
| @@ -430,6 +435,13 @@ open class M18MasterDataService( | |||||
| logger.error("saveItem duplicate code for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}") | logger.error("saveItem duplicate code for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}") | ||||
| return@forEach | 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...") | logger.info("Processing item uom...") | ||||
| val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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> | |||||
| } | |||||
| @@ -83,6 +83,7 @@ class LaserBag2AutoSendService( | |||||
| jobOrderId = jo.id, | jobOrderId = jo.id, | ||||
| jobOrderNo = jo.code, | jobOrderNo = jo.code, | ||||
| lotNo = jo.lotNo, | lotNo = jo.lotNo, | ||||
| expiryDate = jo.expiryDate?.toString(), | |||||
| source = "AUTO", | source = "AUTO", | ||||
| ), | ), | ||||
| ) | ) | ||||
| @@ -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] | |||||
| } | |||||
| @@ -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, | |||||
| ) | |||||
| @@ -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" | |||||
| } | |||||
| @@ -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, | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,13 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.service | |||||
| internal object OnPackXml { | |||||
| fun escapeText(value: String): String = | |||||
| value.replace("&", "&").replace("<", "<").replace(">", ">") | |||||
| 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 | |||||
| } | |||||
| } | |||||
| @@ -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.LaserRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse | import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest | 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.master.service.ItemUomService | ||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | import com.ffii.fpsms.modules.settings.service.SettingsService | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| @@ -56,10 +57,20 @@ import java.time.Duration | |||||
| import java.time.Instant | import java.time.Instant | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.ZoneId | import java.time.ZoneId | ||||
| import java.time.format.DateTimeFormatter | |||||
| // Data class to store bitmap bytes + width (for XML) | // Data class to store bitmap bytes + width (for XML) | ||||
| data class BitmapResult(val bytes: ByteArray, val width: Int) | 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]). */ | /** One Bag2-style laser TCP attempt (internal to [PlasticBagPrinterService]). */ | ||||
| private data class LaserBag2TcpResult( | private data class LaserBag2TcpResult( | ||||
| val success: Boolean, | val success: Boolean, | ||||
| @@ -75,10 +86,12 @@ class PlasticBagPrinterService( | |||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, | |||||
| private val settingsService: SettingsService, | private val settingsService: SettingsService, | ||||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | ||||
| private val environment: Environment, | private val environment: Environment, | ||||
| private val objectMapper: ObjectMapper, | private val objectMapper: ObjectMapper, | ||||
| private val onPackTemplateFileService: OnPackTemplateFileService, | |||||
| ) { | ) { | ||||
| private val logger = LoggerFactory.getLogger(javaClass) | private val logger = LoggerFactory.getLogger(javaClass) | ||||
| private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong") | 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_BAG2_PORT = 45678 | ||||
| private const val DEFAULT_LASER_ITEM_CODES = "PP1175" | private const val DEFAULT_LASER_ITEM_CODES = "PP1175" | ||||
| private const val PACKAGING_PROCESS_NAME = "包裝" | 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 { | fun getLaserBag2Settings(): LaserBag2SettingsResponse { | ||||
| @@ -176,8 +250,22 @@ class PlasticBagPrinterService( | |||||
| } | } | ||||
| val ids = filtered.mapNotNull { it.id } | val ids = filtered.mapNotNull { it.id } | ||||
| val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | 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 -> | 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, | stockInLineId = request.stockInLineId, | ||||
| itemCode = request.itemCode, | itemCode = request.itemCode, | ||||
| itemName = request.itemName, | itemName = request.itemName, | ||||
| expiryDate = request.expiryDate, | |||||
| ) | ) | ||||
| val response = if (first.success) { | val response = if (first.success) { | ||||
| LaserBag2SendResponse( | LaserBag2SendResponse( | ||||
| @@ -220,6 +309,7 @@ class PlasticBagPrinterService( | |||||
| stockInLineId = request.stockInLineId, | stockInLineId = request.stockInLineId, | ||||
| itemCode = request.itemCode, | itemCode = request.itemCode, | ||||
| itemName = request.itemName, | itemName = request.itemName, | ||||
| expiryDate = request.expiryDate, | |||||
| ) | ) | ||||
| LaserBag2SendResponse( | LaserBag2SendResponse( | ||||
| success = second.success, | success = second.success, | ||||
| @@ -292,14 +382,9 @@ class PlasticBagPrinterService( | |||||
| stockInLineId: Long?, | stockInLineId: Long?, | ||||
| itemCode: String?, | itemCode: String?, | ||||
| itemName: String?, | itemName: String?, | ||||
| expiryDate: String? = null, | |||||
| ): LaserBag2TcpResult { | ): 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) | val bytes = payload.toByteArray(StandardCharsets.UTF_8) | ||||
| var socket: Socket? = null | var socket: Socket? = null | ||||
| try { | try { | ||||
| @@ -564,7 +649,21 @@ class PlasticBagPrinterService( | |||||
| return baos.toByteArray() | 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 | val normalizedJobOrders = jobOrders | ||||
| .map { | .map { | ||||
| OnPackQrJobOrderRequest( | OnPackQrJobOrderRequest( | ||||
| @@ -608,31 +707,98 @@ class PlasticBagPrinterService( | |||||
| ?: return@mapNotNull null | ?: return@mapNotNull null | ||||
| val itemId = stockInLine.item?.id ?: return@mapNotNull null | val itemId = stockInLine.item?.id ?: return@mapNotNull null | ||||
| val stockInLineId = stockInLine.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" } | 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 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" } | 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() | val baos = ByteArrayOutputStream() | ||||
| ZipOutputStream(baos).use { zos -> | ZipOutputStream(baos).use { zos -> | ||||
| val addedEntries = linkedSetOf<String>() | 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. | // Target approximately 470x389 BMP, but with larger visible QR and very little vertical whitespace. | ||||
| // Width = 386 + (42 * 2) = 470 | // Width = 386 + (42 * 2) = 470 | ||||
| // Height = 386 + (1 * 2) = 388 (~389) | // Height = 386 + (1 * 2) = 388 (~389) | ||||
| val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1) | val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1) | ||||
| val qrBmpFileName = "${codeLower}qr.bmp" | val qrBmpFileName = "${codeLower}qr.bmp" | ||||
| val imageFileName = "$codeLower.image" | 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)) { | if (addedEntries.add(qrBmpFileName)) { | ||||
| addToZip(zos, qrBmpFileName, bmp.bytes) | addToZip(zos, qrBmpFileName, bmp.bytes) | ||||
| @@ -640,6 +806,23 @@ class PlasticBagPrinterService( | |||||
| if (addedEntries.add(imageFileName)) { | if (addedEntries.add(imageFileName)) { | ||||
| addToZip(zos, imageFileName, imageContent) | 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" } | 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). | * 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. | * 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 | val normalizedJobOrders = jobOrders | ||||
| .map { | .map { | ||||
| OnPackQrJobOrderRequest( | OnPackQrJobOrderRequest( | ||||
| @@ -711,11 +899,25 @@ class PlasticBagPrinterService( | |||||
| val addedEntries = linkedSetOf<String>() | val addedEntries = linkedSetOf<String>() | ||||
| exportItems.forEach { (codeLower, itemId, stockInLineId) -> | exportItems.forEach { (codeLower, itemId, stockInLineId) -> | ||||
| val imageTemplate = loadOnPack2030_2ImageTemplateOrNull(codeLower) ?: run { | 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 | return@forEach | ||||
| } | } | ||||
| val imageFileName = "${codeLower.uppercase()}.image" | 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)) { | if (addedEntries.add(imageFileName)) { | ||||
| addToZip(zos, imageFileName, imageContent) | addToZip(zos, imageFileName, imageContent) | ||||
| } | } | ||||
| @@ -723,7 +925,7 @@ class PlasticBagPrinterService( | |||||
| extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> | extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> | ||||
| if (!addedEntries.add(bmpName)) return@forEach | if (!addedEntries.add(bmpName)) return@forEach | ||||
| val bmpBytes = loadOnPack2030_2AssetOrNull(bmpName) ?: run { | 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 | return@forEach | ||||
| } | } | ||||
| addToZip(zos, bmpName, bmpBytes) | addToZip(zos, bmpName, bmpBytes) | ||||
| @@ -816,10 +1018,26 @@ class PlasticBagPrinterService( | |||||
| else -> return emptySet() | else -> return emptySet() | ||||
| } | } | ||||
| val rows = jdbcDao.queryForList(sql, mapOf("codes" to codesUpper.toList())) | 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 resourcePath = "onpack2030/${codeLower}.image" | ||||
| val resource = ClassPathResource(resourcePath) | val resource = ClassPathResource(resourcePath) | ||||
| if (!resource.exists()) return null | if (!resource.exists()) return null | ||||
| @@ -828,6 +1046,7 @@ class PlasticBagPrinterService( | |||||
| /** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */ | /** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */ | ||||
| private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? { | private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? { | ||||
| onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_LEMON, codeLower)?.let { return it } | |||||
| val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image" | val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image" | ||||
| val resource = ClassPathResource(resourcePath) | val resource = ClassPathResource(resourcePath) | ||||
| if (!resource.exists()) return null | if (!resource.exists()) return null | ||||
| @@ -836,11 +1055,24 @@ class PlasticBagPrinterService( | |||||
| private fun loadOnPack2030_2AssetOrNull(fileName: String): ByteArray? { | private fun loadOnPack2030_2AssetOrNull(fileName: String): ByteArray? { | ||||
| val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null } | val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null } | ||||
| onPackTemplateFileService.loadAsset(OnPackTemplateFileService.MACHINE_LEMON, safe)?.let { return it } | |||||
| val resource = ClassPathResource("onpack2030_2/$safe") | val resource = ClassPathResource("onpack2030_2/$safe") | ||||
| if (!resource.exists()) return null | if (!resource.exists()) return null | ||||
| return resource.inputStream.use { it.readBytes() } | 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). */ | /** Collect `<FileName>xxx.bmp</FileName>` inside each `<Logo>...</Logo>` block (decoded template XML). */ | ||||
| private fun extractLogoBmpFileNamesFromOnPackImageXml(xml: String): Set<String> { | private fun extractLogoBmpFileNamesFromOnPackImageXml(xml: String): Set<String> { | ||||
| val out = linkedSetOf<String>() | val out = linkedSetOf<String>() | ||||
| @@ -853,14 +1085,17 @@ class PlasticBagPrinterService( | |||||
| } | } | ||||
| private fun withOnPackLogo4Bmp(imageBytes: ByteArray, qrBmpFileName: String): ByteArray { | 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). | * substring/regex matching (`hasNameQr=false` while XML is valid). | ||||
| */ | */ | ||||
| private fun decodeOnPackImageTemplateForTextEdit(bytes: ByteArray): Pair<String, (String) -> ByteArray> { | 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 payload = """{"itemId": $itemId, "stockInLineId": $stockInLineId}""" | ||||
| val (oneByteText, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) | val (oneByteText, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) | ||||
| // Must NOT use Static[^>]*StaticSrc — [^>]* already eats type='StaticSrc.V1', so StaticSrc can never match. | // 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) | 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). */ | /** 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) + "…" | 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 { | private fun createMonochromeBitmap(text: String, targetHeight: Int): BitmapResult { | ||||
| // Step 1: Measure text width with temporary image | // Step 1: Measure text width with temporary image | ||||
| val tempImg = BufferedImage(1, 1, BufferedImage.TYPE_BYTE_BINARY) | val tempImg = BufferedImage(1, 1, BufferedImage.TYPE_BYTE_BINARY) | ||||
| @@ -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"))) | |||||
| } | |||||
| } | |||||
| @@ -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") | @PostMapping("/print-laser-bag2") | ||||
| fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity<LaserBag2SendResponse> { | 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). */ | /** OnPack2023 檸檬機: `onpack2030_2` templates with embedded QR (Static text only; no separate .bmp). */ | ||||
| @PostMapping("/download-onpack-qr-text") | @PostMapping("/download-onpack-qr-text") | ||||
| fun downloadOnPackQrText( | fun downloadOnPackQrText( | ||||
| @@ -194,6 +240,50 @@ class PlasticBagPrinterController( | |||||
| return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders)) | 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. | * Test API to generate and download the printer job files as a ZIP. | ||||
| * ONPACK2030 | * ONPACK2030 | ||||
| @@ -1,7 +1,8 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.web.model | 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 [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] | * 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 jobOrderId: Long? = null, | ||||
| val jobOrderNo: String? = null, | val jobOrderNo: String? = null, | ||||
| val lotNo: 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. */ | /** AUTO (auto-send) or MANUAL (/laserPrint); optional. */ | ||||
| val source: String? = null, | val source: String? = null, | ||||
| ) | ) | ||||
| @@ -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>, | |||||
| ) | |||||
| @@ -52,6 +52,8 @@ data class PrinterStatusResponse( | |||||
| data class OnPackQrDownloadRequest( | data class OnPackQrDownloadRequest( | ||||
| val jobOrders: List<OnPackQrJobOrderRequest>, | 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( | data class OnPackQrJobOrderRequest( | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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> | |||||
| } | |||||
| @@ -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 } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -581,6 +581,10 @@ open class ItemsService( | |||||
| return itemsRepository.findByCodeAndDeletedFalse(code); | return itemsRepository.findByCodeAndDeletedFalse(code); | ||||
| } | } | ||||
| open fun findByCodeAndType(code: String, type: String): Items? { | |||||
| return itemsRepository.findByCodeAndTypeAndDeletedFalse(code, type) | |||||
| } | |||||
| open fun findByM18Id(m18Id: Long): Items? { | open fun findByM18Id(m18Id: Long): Items? { | ||||
| return itemsRepository.findByM18IdAndDeletedIsFalse(m18Id) | return itemsRepository.findByM18IdAndDeletedIsFalse(m18Id) | ||||
| } | } | ||||
| @@ -650,34 +654,67 @@ open class ItemsService( | |||||
| @Transactional | @Transactional | ||||
| open fun saveItem(request: NewItemRequest): MessageResponse { | open fun saveItem(request: NewItemRequest): MessageResponse { | ||||
| val duplicatedItem = itemsRepository.findByCodeAndTypeAndDeletedFalse(request.code, request.type) | val duplicatedItem = itemsRepository.findByCodeAndTypeAndDeletedFalse(request.code, request.type) | ||||
| val ownerOfNewM18Id = request.m18Id?.let { findByM18Id(it) } | |||||
| if (duplicatedItem != null && duplicatedItem.id != request.id) { | 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( | 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( | return MessageResponse( | ||||
| id = request.id ?: duplicatedItem.id, | |||||
| id = request.id ?: item.id, | |||||
| code = request.code, | code = request.code, | ||||
| name = request.name, | name = request.name, | ||||
| type = request.type.toString(), | 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( | return MessageResponse( | ||||
| id = item.id, | id = item.id, | ||||
| code = item.code, | code = item.code, | ||||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.py | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | ||||
| import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService | 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.master.service.ItemUomService | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import org.springframework.format.annotation.DateTimeFormat | import org.springframework.format.annotation.DateTimeFormat | ||||
| @@ -28,6 +29,7 @@ open class PyController( | |||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val plasticBagPrinterService: PlasticBagPrinterService, | private val plasticBagPrinterService: PlasticBagPrinterService, | ||||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | ||||
| private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, | |||||
| ) { | ) { | ||||
| companion object { | companion object { | ||||
| private const val PACKAGING_PROCESS_NAME = "包裝" | private const val PACKAGING_PROCESS_NAME = "包裝" | ||||
| @@ -53,8 +55,22 @@ open class PyController( | |||||
| ) | ) | ||||
| val ids = orders.mapNotNull { it.id } | val ids = orders.mapNotNull { it.id } | ||||
| val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | 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 -> | 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) | return ResponseEntity.ok(list) | ||||
| } | } | ||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.py | package com.ffii.fpsms.py | ||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import java.time.LocalDate | |||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| /** | /** | ||||
| @@ -27,4 +28,10 @@ data class PyJobOrderListItem( | |||||
| val labelPrintedQty: Long = 0, | val labelPrintedQty: Long = 0, | ||||
| /** Cumulative qty from 激光機 submits (LASER). */ | /** Cumulative qty from 激光機 submits (LASER). */ | ||||
| val laserPrintedQty: Long = 0, | 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, | |||||
| ) | ) | ||||
| @@ -1,8 +1,11 @@ | |||||
| package com.ffii.fpsms.py | package com.ffii.fpsms.py | ||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrder | 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.master.service.ItemUomService | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import java.time.LocalDate | |||||
| object PyJobOrderListMapper { | object PyJobOrderListMapper { | ||||
| @@ -80,6 +83,9 @@ object PyJobOrderListMapper { | |||||
| printed: PrintedQtyByChannel?, | printed: PrintedQtyByChannel?, | ||||
| stockInLineRepository: StockInLineRepository, | stockInLineRepository: StockInLineRepository, | ||||
| itemUomService: ItemUomService, | itemUomService: ItemUomService, | ||||
| defaultShelfLifeDays: Int? = null, | |||||
| useMinus18: Boolean? = null, | |||||
| expiryDate: LocalDate? = null, | |||||
| ): PyJobOrderListItem { | ): PyJobOrderListItem { | ||||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | val itemCode = jo.bom?.item?.code ?: jo.bom?.code | ||||
| val baseName = jo.bom?.name ?: jo.bom?.item?.name | val baseName = jo.bom?.name ?: jo.bom?.item?.name | ||||
| @@ -107,6 +113,9 @@ object PyJobOrderListMapper { | |||||
| bagPrintedQty = p.bagPrintedQty, | bagPrintedQty = p.bagPrintedQty, | ||||
| labelPrintedQty = p.labelPrintedQty, | labelPrintedQty = p.labelPrintedQty, | ||||
| laserPrintedQty = p.laserPrintedQty, | laserPrintedQty = p.laserPrintedQty, | ||||
| defaultShelfLifeDays = defaultShelfLifeDays, | |||||
| useMinus18 = useMinus18, | |||||
| expiryDate = expiryDate, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -116,6 +125,9 @@ object PyJobOrderListMapper { | |||||
| printed: PrintedQtyByChannel?, | printed: PrintedQtyByChannel?, | ||||
| stockInLineRepository: StockInLineRepository, | stockInLineRepository: StockInLineRepository, | ||||
| itemUomService: ItemUomService, | itemUomService: ItemUomService, | ||||
| defaultShelfLifeDays: Int? = null, | |||||
| useMinus18: Boolean? = null, | |||||
| expiryDate: LocalDate? = null, | |||||
| ): PyJobOrderListItem { | ): PyJobOrderListItem { | ||||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | val itemCode = jo.bom?.item?.code ?: jo.bom?.code | ||||
| val baseName = jo.bom?.name ?: jo.bom?.item?.name | val baseName = jo.bom?.name ?: jo.bom?.item?.name | ||||
| @@ -143,6 +155,20 @@ object PyJobOrderListMapper { | |||||
| bagPrintedQty = p.bagPrintedQty, | bagPrintedQty = p.bagPrintedQty, | ||||
| labelPrintedQty = p.labelPrintedQty, | labelPrintedQty = p.labelPrintedQty, | ||||
| laserPrintedQty = p.laserPrintedQty, | 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) | |||||
| } | |||||
| } | } | ||||
| @@ -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`) | |||||
| ); | |||||
| @@ -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'); | |||||
| @@ -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 | |||||
| @@ -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`; | |||||
| @@ -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; | |||||
| @@ -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`) | |||||
| ); | |||||
| @@ -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(" ")) | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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>")) | |||||
| } | |||||
| } | |||||
| @@ -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")) | |||||
| } | |||||
| } | |||||
| @@ -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() | |||||
| } | |||||
| @@ -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)), | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -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", | |||||
| ) | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Assertions.assertEquals | |||||
| import org.junit.jupiter.api.Assertions.assertFalse | import org.junit.jupiter.api.Assertions.assertFalse | ||||
| import org.junit.jupiter.api.Assertions.assertTrue | import org.junit.jupiter.api.Assertions.assertTrue | ||||
| import org.junit.jupiter.api.Test | import org.junit.jupiter.api.Test | ||||
| import java.time.LocalDate | |||||
| import com.ffii.fpsms.modules.master.service.ItemPrintShelfLife | |||||
| class PyJobOrderListMapperTest { | class PyJobOrderListMapperTest { | ||||
| @@ -64,14 +66,38 @@ class PyJobOrderListMapperTest { | |||||
| } | } | ||||
| @Test | @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) | |||||
| } | |||||
| } | } | ||||