# Conflicts: # src/main/java/com/ffii/fpsms/modules/master/service/BomService.ktbomUpdateTest
| @@ -93,6 +93,10 @@ kotlin { | |||
| jvmToolchain(17) | |||
| } | |||
| test { | |||
| useJUnitPlatform() | |||
| } | |||
| bootRun { | |||
| // Use db-local profile by default so datasource is loaded (application-db-local.yml). | |||
| // Override with: ./gradlew bootRun --args='--spring.profiles.active=prod' | |||
| @@ -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 pywin32 | |||
| py -m pip install --upgrade Pillow "qrcode[pil]" requests | |||
| py -m pip install --upgrade Pillow "qrcode[pil]" requests pyserial | |||
| py -m PyInstaller --noconfirm --clean Bag3.spec | |||
| # --- Bag4 (DataFlex with expiry) --- | |||
| py -m PyInstaller --noconfirm --clean Bag4.spec | |||
| # Output: dist\Bag4.exe (one-file). Copy Bag4.exe to the client PC. | |||
| # Settings are created next to the exe: bag4_settings.json | |||
| # Do not overwrite Bag3.exe; Bag3 and Bag4 are separate. | |||
| # Output: dist\Bag3\Bag3.exe plus dist\Bag3\_internal\... | |||
| # Copy the ENTIRE dist\Bag3\ folder to the client PC (not only Bag3.exe). | |||
| # --- Bag3 (DataFlex without expiry) --- | |||
| py -m PyInstaller --noconfirm --clean Bag3.spec | |||
| # Output: dist\Bag3.exe | |||
| # --- If the client exe flashes and closes --- | |||
| 1) On the client PC, open cmd in the Bag3 folder and run: | |||
| Bag3.exe | |||
| You should see the error in the console, or open bag3_startup_error.log next to Bag3.exe. | |||
| 1) On the client PC, open cmd in the folder that contains the exe and run: | |||
| Bag4.exe | |||
| You should see the error in the console, or open bag4_startup_error.log next to Bag4.exe. | |||
| (Bag3: bag3_startup_error.log) | |||
| 2) Compare BUILD machines (both should match): | |||
| py --version | |||
| py -m PyInstaller --version | |||
| py -m pip show pyinstaller pywin32 Pillow qrcode requests | |||
| py -m pip show pyinstaller pywin32 Pillow qrcode requests pyserial | |||
| A broken build is often caused by: | |||
| - Different Python major version (e.g. 3.13 vs 3.11) | |||
| @@ -28,8 +34,8 @@ py -m PyInstaller --noconfirm --clean Bag3.spec | |||
| 3) Rebuild on the machine that works, or reinstall Python from python.org (64-bit) | |||
| and reinstall deps above, then rebuild. | |||
| 4) Bag3.spec disables UPX (upx=False) for stability; do not re-enable unless you test on the client. | |||
| 4) Spec files disable UPX (upx=False) for stability; do not re-enable unless you test on the client. | |||
| 5) Client needs 64-bit Windows and Microsoft VC++ Redistributable (same as your Python installer). | |||
| 6) Antivirus may quarantine files under _internal\ — whitelist the Bag3 folder if the log mentions missing DLL. | |||
| 6) Antivirus may quarantine the exe — whitelist the folder if the log mentions missing DLL. | |||
| @@ -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() | |||
| @@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer { | |||
| registry.addMapping("/**") | |||
| .allowedHeaders("*") | |||
| .allowedOrigins("*") | |||
| .exposedHeaders("filename", "Content-Disposition") | |||
| .exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry") | |||
| .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"); | |||
| } | |||
| @@ -75,6 +75,7 @@ public class SecurityConfig { | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 | |||
| * (stockAdjustment/submit → INVENTORY_ADJUST only) | |||
| @@ -128,6 +129,15 @@ public class SecurityConfig { | |||
| /* 工序「已完成」(Just Pass):僅 ADMIN */ | |||
| .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**") | |||
| .hasAuthority("ADMIN") | |||
| /* M18 手動同步頁:ADMIN 或 M18_SYNC。po-by-code 另允許 PURCHASE(採購單搜尋自動同步)。 */ | |||
| .requestMatchers(HttpMethod.GET, "/m18/test/po-by-code") | |||
| .hasAnyAuthority("ADMIN", "M18_SYNC", "PURCHASE") | |||
| .requestMatchers(HttpMethod.GET, "/m18/test/do-by-code") | |||
| .hasAnyAuthority("ADMIN", "M18_SYNC") | |||
| .requestMatchers(HttpMethod.GET, "/m18/test/do-by-code-extra") | |||
| .hasAnyAuthority("ADMIN", "M18_SYNC") | |||
| .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") | |||
| .hasAnyAuthority("ADMIN", "M18_SYNC") | |||
| .anyRequest().authenticated()) | |||
| .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | |||
| (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | |||
| @@ -195,6 +195,17 @@ open class M18MasterDataService( | |||
| return itemsService.findByM18Id(m18ItemId)?.id | |||
| } | |||
| private fun mapM18ProductType(udfProducttype: String?): String { | |||
| return when (udfProducttype) { | |||
| M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type | |||
| M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type | |||
| M18ItemType.FG.type -> ItemType.FG.type | |||
| M18ItemType.SFG.type -> ItemType.SFG.type | |||
| M18ItemType.ITEM.type -> ItemType.ITEM.type | |||
| else -> ItemType.MATERIAL.type | |||
| } | |||
| } | |||
| open fun saveProduct(id: Long): MessageResponse? { | |||
| try { | |||
| ensureCunitSeededForAllIfEmpty() | |||
| @@ -203,20 +214,14 @@ open class M18MasterDataService( | |||
| val price = itemDetail?.data?.price | |||
| if (itemDetail != null && pro != null) { | |||
| val mappedType = mapM18ProductType(pro.udfProducttype) | |||
| val existingItem = itemsService.findByM18Id(id) | |||
| val saveItemRequest = NewItemRequest( | |||
| code = pro.code, | |||
| name = pro.desc, | |||
| // type = if (pro.seriesId == m18Config.SERIESID_PF) ProductType.MATERIAL | |||
| // else ItemType.PRODUCT, | |||
| type = when (pro.udfProducttype) { | |||
| M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type | |||
| M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type | |||
| M18ItemType.FG.type -> ItemType.FG.type | |||
| M18ItemType.SFG.type -> ItemType.SFG.type | |||
| M18ItemType.ITEM.type -> ItemType.ITEM.type | |||
| else -> ItemType.MATERIAL.type | |||
| }, | |||
| type = mappedType, | |||
| id = existingItem?.id, | |||
| description = pro.desc, | |||
| remarks = null, | |||
| @@ -247,6 +252,12 @@ open class M18MasterDataService( | |||
| logger.error("saveItem duplicate code for M18 item $id (code=${pro.code}): ${savedItem.message}") | |||
| return null | |||
| } | |||
| if (ItemM18IdRemapSupport.isM18IdLinkOnly(existingItem == null, savedItem.message)) { | |||
| logger.warn( | |||
| "Linked M18 product id=$id code=${pro.code} to existing local item id=$localItemId; skip UOM rebuild" | |||
| ) | |||
| return savedItem.copy(id = localItemId) | |||
| } | |||
| logger.info("Processing item uom...") | |||
| // Find the item uom that ready to delete (not in m18) | |||
| val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | |||
| @@ -382,6 +393,7 @@ open class M18MasterDataService( | |||
| val price = itemDetail?.data?.price | |||
| if (itemDetail != null && pro != null) { | |||
| val mappedType = mapM18ProductType(pro.udfProducttype) | |||
| // ── Use cache instead of direct call ──────────────────────── | |||
| val existingItem = itemCache.getOrPut(item.id) { | |||
| itemsService.findByM18Id(item.id) | |||
| @@ -390,14 +402,7 @@ open class M18MasterDataService( | |||
| val saveItemRequest = NewItemRequest( | |||
| code = pro.code, | |||
| name = pro.desc, | |||
| type = when (pro.udfProducttype) { | |||
| M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type | |||
| M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type | |||
| M18ItemType.FG.type -> ItemType.FG.type | |||
| M18ItemType.SFG.type -> ItemType.SFG.type | |||
| M18ItemType.ITEM.type -> ItemType.ITEM.type | |||
| else -> ItemType.MATERIAL.type | |||
| }, | |||
| type = mappedType, | |||
| id = existingItem?.id, | |||
| description = pro.desc, | |||
| remarks = null, | |||
| @@ -430,6 +435,13 @@ open class M18MasterDataService( | |||
| logger.error("saveItem duplicate code for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}") | |||
| return@forEach | |||
| } | |||
| if (ItemM18IdRemapSupport.isM18IdLinkOnly(existingItem == null, savedItem.message)) { | |||
| logger.warn( | |||
| "Linked M18 product id=${item.id} code=${pro.code} to existing local item id=$localItemId; skip UOM rebuild" | |||
| ) | |||
| successList.add(item.id) | |||
| return@forEach | |||
| } | |||
| logger.info("Processing item uom...") | |||
| val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | |||
| @@ -717,9 +717,13 @@ open class ChartService( | |||
| } | |||
| /** | |||
| * Staff delivery performance: daily pick ticket count and total time per staff. | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 | |||
| * Staff delivery performance: daily pick ticket count, duration, item kinds, and actual picked qty. | |||
| * Uses delivery_order_pick_order (handler = handledBy); time = sum of | |||
| * (ticketCompleteDateTime - ticketReleaseTime) per completed ticket. | |||
| * itemKindCount = sum of per-ticket COUNT(DISTINCT pol.itemId); | |||
| * itemQtyPicked = sum of stock_out_line.qty via pick_order_line (actual picked, not pol.qty). | |||
| * Scoped CTE first (date/store/staff) then lineAgg — avoids full-history pick/sol scan. | |||
| * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). | |||
| * storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId). | |||
| * storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed). | |||
| @@ -742,12 +746,13 @@ open class ChartService( | |||
| args["endExclusive"] = endDate.plusDays(1).atStartOfDay() | |||
| "AND dop.ticketCompleteDateTime < :endExclusive" | |||
| } else "" | |||
| val staffSql = if (!staffNos.isNullOrEmpty()) { | |||
| val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } | |||
| if (nos.isEmpty()) "" else { | |||
| args["staffNos"] = nos | |||
| "AND u.staffNo IN (:staffNos)" | |||
| } | |||
| val staffNosFiltered = staffNos?.map { it.trim() }?.filter { it.isNotBlank() }.orEmpty() | |||
| val staffSql = if (staffNosFiltered.isNotEmpty()) { | |||
| args["staffNos"] = staffNosFiltered | |||
| "AND u_scope.staffNo IN (:staffNos)" | |||
| } else "" | |||
| val scopeUserJoin = if (staffSql.isNotEmpty()) { | |||
| "LEFT JOIN user u_scope ON dop.handledBy = u_scope.id AND u_scope.deleted = 0" | |||
| } else "" | |||
| val storeSql = when { | |||
| storeIdNull == true -> "AND dop.storeId IS NULL" | |||
| @@ -758,31 +763,61 @@ open class ChartService( | |||
| else -> "" | |||
| } | |||
| val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank() | |||
| val fromClause = if (useStoreFilter) { | |||
| val dopFromClause = if (useStoreFilter) { | |||
| "FROM delivery_order_pick_order dop" | |||
| } else { | |||
| "FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)" | |||
| } | |||
| val sql = """ | |||
| WITH dop_scoped AS ( | |||
| SELECT | |||
| dop.id, | |||
| dop.ticketCompleteDateTime, | |||
| dop.ticketReleaseTime, | |||
| dop.handledBy, | |||
| dop.handlerName | |||
| $dopFromClause | |||
| $scopeUserJoin | |||
| WHERE dop.deleted = 0 | |||
| AND dop.ticketStatus = 'completed' | |||
| AND dop.ticketCompleteDateTime IS NOT NULL | |||
| $startSql $endSql $storeSql $staffSql | |||
| ), | |||
| lineAgg AS ( | |||
| SELECT | |||
| po.deliveryOrderPickOrderId AS dopId, | |||
| COUNT(DISTINCT pol.itemId) AS itemKindCount, | |||
| COALESCE(SUM(sol.qty), 0) AS itemQtyPicked | |||
| FROM dop_scoped d | |||
| INNER JOIN pick_order po | |||
| ON po.deliveryOrderPickOrderId = d.id | |||
| AND po.deleted = 0 | |||
| INNER JOIN pick_order_line pol | |||
| ON pol.poId = po.id | |||
| AND pol.deleted = 0 | |||
| LEFT JOIN stock_out_line sol FORCE INDEX (idx_sol_polid_deleted_status_qty) | |||
| ON sol.pickOrderLineId = pol.id | |||
| AND sol.deleted = 0 | |||
| GROUP BY po.deliveryOrderPickOrderId | |||
| ) | |||
| SELECT | |||
| DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date, | |||
| COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName, | |||
| COUNT(dop.id) AS orderCount, | |||
| DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d') AS date, | |||
| COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), d.handlerName, 'Unknown') AS staffName, | |||
| COUNT(d.id) AS orderCount, | |||
| COALESCE(SUM( | |||
| CASE | |||
| WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL | |||
| THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime)) | |||
| WHEN d.ticketReleaseTime IS NOT NULL AND d.ticketCompleteDateTime IS NOT NULL | |||
| THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, d.ticketReleaseTime, d.ticketCompleteDateTime)) | |||
| ELSE 0 | |||
| END | |||
| ), 0) AS totalMinutes | |||
| $fromClause | |||
| LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0 | |||
| WHERE dop.deleted = 0 | |||
| AND dop.ticketStatus = 'completed' | |||
| AND dop.ticketCompleteDateTime IS NOT NULL | |||
| $startSql $endSql $staffSql $storeSql | |||
| GROUP BY DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d'), | |||
| dop.handledBy, u.name, dop.handlerName | |||
| ), 0) AS totalMinutes, | |||
| COALESCE(SUM(la.itemKindCount), 0) AS itemKindCount, | |||
| COALESCE(SUM(la.itemQtyPicked), 0) AS itemQtyPicked | |||
| FROM dop_scoped d | |||
| LEFT JOIN user u ON d.handledBy = u.id AND u.deleted = 0 | |||
| LEFT JOIN lineAgg la ON la.dopId = d.id | |||
| GROUP BY DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d'), | |||
| d.handledBy, u.name, d.handlerName | |||
| ORDER BY date, orderCount DESC | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| @@ -194,9 +194,13 @@ class ChartController( | |||
| chartService.getStaffDeliveryPerformanceHandlers() | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 | |||
| * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002&storeId=2/F&storeIdNull=true | |||
| * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from delivery_order_pick_order | |||
| * (handledBy), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). | |||
| * Returns [{ date, staffName, orderCount, totalMinutes, itemKindCount, itemQtyPicked }]. | |||
| * Data from delivery_order_pick_order (handledBy); orderCount = completed pick tickets; | |||
| * totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime); | |||
| * itemKindCount = sum of per-ticket COUNT(DISTINCT pick_order_line.itemId); | |||
| * itemQtyPicked = sum of stock_out_line.qty via pick_order_line. | |||
| * Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId). | |||
| */ | |||
| @GetMapping("/staff-delivery-performance") | |||
| @@ -10,6 +10,8 @@ public class ErrorCodes { | |||
| public static final String SEND_EMAIL_ERROR = "SEND_EMAIL_ERROR"; | |||
| public static final String USERNAME_NOT_AVAILABLE = "USERNAME_NOT_AVAILABLE"; | |||
| public static final String NAME_NOT_AVAILABLE = "NAME_NOT_AVAILABLE"; | |||
| public static final String STAFF_NO_NOT_AVAILABLE = "STAFF_NO_NOT_AVAILABLE"; | |||
| public static final String INIT_EXCEL_ERROR = "INIT_EXCEL_ERROR"; | |||
| @@ -244,9 +244,10 @@ open class DoWorkbenchMainService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 | |||
| * Warehouse exclude list for workbench **re-suggest** after scan-pick (shortfall / lot split). | |||
| * - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES]). | |||
| * - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else `null`. | |||
| * - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else empty (no limit). | |||
| * - DO / other: pass through request (`null` → service default excludes). | |||
| */ | |||
| private fun workbenchResuggestExcludeWarehouseCodes( | |||
| @@ -267,7 +268,7 @@ open class DoWorkbenchMainService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 | |||
| * Workbench scan-pick (DO FG): | |||
| * 1) Post outbound on scanned inventory lot line first; on failure return a clear message. | |||
| * 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty], | |||
| @@ -2364,6 +2365,7 @@ return MessageResponse( | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 */ | |||
| private fun runWorkbenchPickDeferredFollowUps( | |||
| solId: Long, | |||
| polId: Long, | |||
| @@ -2386,10 +2388,20 @@ return MessageResponse( | |||
| var postMs = 0L | |||
| try { | |||
| if (pickOrderId != null) { | |||
| val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) | |||
| // JO / Consumable prime with storeId=null; re-suggest must match or non-2F lots | |||
| // are filtered out after resolveWorkbenchSuggestionStoreId defaults to "2/F". | |||
| val resolvedPoType = pickOrderRepository.findById(pickOrderId).orElse(null)?.type | |||
| val suggestionStoreId = | |||
| if (resolvedPoType == PickOrderType.JOB_ORDER || | |||
| resolvedPoType == PickOrderType.Consumable | |||
| ) { | |||
| null | |||
| } else { | |||
| resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) | |||
| } | |||
| val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( | |||
| pickOrderId = pickOrderId, | |||
| poType = null, | |||
| poType = resolvedPoType, | |||
| userId = userId, | |||
| requestExcludeWarehouseCodes = effectiveExcludeWarehouseCodes, | |||
| ) | |||
| @@ -2620,7 +2632,7 @@ return MessageResponse( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 | |||
| * DO user-audit: Just Complete is reportable only when the item still has pickable stock | |||
| * at complete time (AVAILABLE, not expired, in−out > 0). | |||
| * | |||
| @@ -3,7 +3,7 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models | |||
| import java.math.BigDecimal | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 | |||
| * Workbench v1: scan lot and post pick immediately (no separate submit step). | |||
| * [qty] optional: when null, posts up to remaining quantity for this stock-out line chunk; when set, may exceed that | |||
| * chunk and is capped only by available quantity on the scanned inventory lot line (overscan / UI edit). | |||
| @@ -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> | |||
| } | |||
| @@ -5,6 +5,7 @@ package com.ffii.fpsms.modules.jobOrder.service | |||
| * | |||
| * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) | |||
| * and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]). | |||
| * JO re-suggest keeps [storeId] null (same as assign); it does not use DO floor store resolution. | |||
| */ | |||
| object JoWorkbenchPickConstants { | |||
| val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf( | |||
| @@ -83,6 +83,7 @@ class LaserBag2AutoSendService( | |||
| jobOrderId = jo.id, | |||
| jobOrderNo = jo.code, | |||
| lotNo = jo.lotNo, | |||
| expiryDate = jo.expiryDate?.toString(), | |||
| 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,34 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.service | |||
| /** | |||
| * Clone 汁水機 expiry ZIP templates from classpath [MASTER_IMAGE] / [MASTER_JOB]. | |||
| * FileName stems `pp1181*` become `{code}*`; `.job` points at the cloned `.image`. | |||
| */ | |||
| object OnPackPp1181Master { | |||
| const val MASTER_CODE = "pp1181" | |||
| const val MASTER_IMAGE = "onpack2030_exp/$MASTER_CODE.image" | |||
| const val MASTER_IMAGE_FALLBACK = "onpack2030/$MASTER_CODE.image" | |||
| const val MASTER_JOB = "onpack2030/$MASTER_CODE.job" | |||
| fun rewriteImageXml(masterXml: String, itemCode: String): String { | |||
| val codeLower = itemCode.trim().lowercase() | |||
| require(codeLower.isNotEmpty()) { "itemCode is blank" } | |||
| return masterXml.replace(Regex("pp1181", RegexOption.IGNORE_CASE), codeLower) | |||
| } | |||
| fun rewriteJobXml(masterXml: String, imageFileName: String): String { | |||
| val name = imageFileName.trim() | |||
| require(name.isNotEmpty()) { "imageFileName is blank" } | |||
| return masterXml.replace(Regex("""(?i)pp1181\.image"""), name) | |||
| } | |||
| fun rewriteImageBytes(masterBytes: ByteArray, itemCode: String): ByteArray { | |||
| val (xml, encodeBack) = OnPackImageTemplateCodec.decode(masterBytes) | |||
| return encodeBack(rewriteImageXml(xml, itemCode)) | |||
| } | |||
| fun rewriteJobBytes(masterBytes: ByteArray, imageFileName: String): ByteArray { | |||
| val (xml, encodeBack) = OnPackImageTemplateCodec.decode(masterBytes) | |||
| return encodeBack(rewriteJobXml(xml, imageFileName)) | |||
| } | |||
| } | |||
| @@ -0,0 +1,577 @@ | |||
| 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.OnPackExpiryItemCodeDto | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeUpdateRequest | |||
| 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 com.ffii.fpsms.modules.master.entity.ItemsRepository | |||
| import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.py.PyJobOrderListMapper | |||
| 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 itemsRepository: ItemsRepository, | |||
| private val itemUomService: ItemUomService, | |||
| private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, | |||
| ) { | |||
| 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), | |||
| ) | |||
| } | |||
| @Transactional(readOnly = true) | |||
| open fun listExpiryItemCodes(machineRaw: String?): List<OnPackExpiryItemCodeDto> { | |||
| val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) | |||
| val rows = expiryCodeRows(machine) | |||
| return enrichExpiryRows(machine, rows) | |||
| } | |||
| /** Uppercase item code → non-blank printName override for Product BMP. */ | |||
| @Transactional(readOnly = true) | |||
| open fun expiryPrintNames(machine: String = MACHINE_JUICE): Map<String, String> { | |||
| val normalized = try { | |||
| normalizeMachine(machine) | |||
| } catch (_: IllegalArgumentException) { | |||
| MACHINE_JUICE | |||
| } | |||
| return expiryCodeRows(normalized) | |||
| .mapNotNull { (code, printName) -> | |||
| val name = printName?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null | |||
| code to name | |||
| } | |||
| .toMap() | |||
| } | |||
| /** Uppercase item codes allowed in 汁水機 expiry ZIP. Falls back to folder PP* if table is empty/missing. */ | |||
| @Transactional(readOnly = true) | |||
| open fun expiryItemCodes(machine: String = MACHINE_JUICE): Set<String> { | |||
| val normalized = try { | |||
| normalizeMachine(machine) | |||
| } catch (_: IllegalArgumentException) { | |||
| MACHINE_JUICE | |||
| } | |||
| val fromTable = try { | |||
| jdbcDao.queryForStrings( | |||
| """ | |||
| SELECT DISTINCT UPPER(TRIM(itemCode)) | |||
| FROM onpack_expiry_item_code | |||
| WHERE deleted = 0 | |||
| AND machine = :machine | |||
| AND TRIM(itemCode) <> '' | |||
| """.trimIndent(), | |||
| mapOf("machine" to normalized), | |||
| ).mapNotNull { it.trim().uppercase().takeIf { code -> code.isNotEmpty() } }.toSet() | |||
| } catch (e: DataAccessException) { | |||
| logger.warn("onpack_expiry_item_code is unavailable; using folder PP* list", e) | |||
| return if (normalized == MACHINE_JUICE) builtinImageCodes(MACHINE_JUICE) else emptySet() | |||
| } | |||
| return fromTable | |||
| } | |||
| @Transactional | |||
| open fun addExpiryItemCode(machineRaw: String?, itemCodeRaw: String): OnPackExpiryItemCodeDto { | |||
| val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) | |||
| val itemCode = normalizeItemCode(itemCodeRaw) | |||
| val existing = try { | |||
| jdbcDao.queryForMap( | |||
| """ | |||
| SELECT id, deleted FROM onpack_expiry_item_code | |||
| WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code | |||
| LIMIT 1 | |||
| """.trimIndent(), | |||
| mapOf("machine" to machine, "code" to itemCode), | |||
| ) | |||
| } catch (e: DataAccessException) { | |||
| throw IllegalStateException("無法儲存品號。請重啟後端以執行 Liquibase(建立 onpack_expiry_item_code)。", e) | |||
| } | |||
| if (existing.isPresent) { | |||
| val row = existing.get() | |||
| val deleted = (row["deleted"] as? Number)?.toInt() == 1 || row["deleted"] == true | |||
| if (deleted) { | |||
| jdbcDao.executeUpdate( | |||
| """ | |||
| UPDATE onpack_expiry_item_code | |||
| SET deleted = 0, modified = NOW(), modifiedBy = 'ui' | |||
| WHERE id = :id | |||
| """.trimIndent(), | |||
| mapOf("id" to row["id"]), | |||
| ) | |||
| } | |||
| } else { | |||
| jdbcDao.executeUpdate( | |||
| """ | |||
| INSERT INTO onpack_expiry_item_code | |||
| (created, createdBy, version, modified, modifiedBy, deleted, machine, itemCode) | |||
| VALUES (NOW(), 'ui', 0, NOW(), 'ui', 0, :machine, :code) | |||
| """.trimIndent(), | |||
| mapOf("machine" to machine, "code" to itemCode), | |||
| ) | |||
| } | |||
| return enrichExpiryRows(machine, listOf(itemCode to loadPrintName(machine, itemCode))).first() | |||
| } | |||
| @Transactional | |||
| open fun updateExpiryItemCode(body: OnPackExpiryItemCodeUpdateRequest): OnPackExpiryItemCodeDto { | |||
| val machine = normalizeMachine(body.machine ?: MACHINE_JUICE) | |||
| val itemCode = normalizeItemCode(body.itemCode) | |||
| if (body.printName != null) { | |||
| val stored = body.printName.trim().takeIf { it.isNotEmpty() }?.take(255) | |||
| try { | |||
| val n = jdbcDao.executeUpdate( | |||
| """ | |||
| UPDATE onpack_expiry_item_code | |||
| SET printName = :printName, modified = NOW(), modifiedBy = 'ui' | |||
| WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 | |||
| """.trimIndent(), | |||
| mapOf("machine" to machine, "code" to itemCode, "printName" to stored), | |||
| ) | |||
| if (n == 0) { | |||
| throw IllegalArgumentException("清單中沒有 $itemCode") | |||
| } | |||
| } catch (e: DataAccessException) { | |||
| throw IllegalStateException("無法儲存列印名稱。請重啟後端以執行 Liquibase(printName 欄位)。", e) | |||
| } | |||
| } | |||
| if (body.useMinus18 != null) { | |||
| itemDefaultShelfLifeService.setUseMinus18(itemCode, body.useMinus18) | |||
| } | |||
| return enrichExpiryRows(machine, listOf(itemCode to loadPrintName(machine, itemCode))).first() | |||
| } | |||
| @Transactional | |||
| open fun removeExpiryItemCode(machineRaw: String?, itemCodeRaw: String) { | |||
| val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) | |||
| val itemCode = normalizeItemCode(itemCodeRaw) | |||
| try { | |||
| jdbcDao.executeUpdate( | |||
| """ | |||
| UPDATE onpack_expiry_item_code | |||
| SET deleted = 1, modified = NOW(), modifiedBy = 'ui' | |||
| WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 | |||
| """.trimIndent(), | |||
| mapOf("machine" to machine, "code" to itemCode), | |||
| ) | |||
| } catch (e: DataAccessException) { | |||
| throw IllegalStateException("無法刪除品號。請重啟後端以執行 Liquibase(建立 onpack_expiry_item_code)。", e) | |||
| } | |||
| } | |||
| private fun expiryCodeRows(machine: String): List<Pair<String, String?>> { | |||
| return try { | |||
| jdbcDao.queryForList( | |||
| """ | |||
| SELECT UPPER(TRIM(itemCode)) AS itemCode, printName | |||
| FROM onpack_expiry_item_code | |||
| WHERE deleted = 0 | |||
| AND machine = :machine | |||
| AND TRIM(itemCode) <> '' | |||
| ORDER BY itemCode | |||
| """.trimIndent(), | |||
| mapOf("machine" to machine), | |||
| ).mapNotNull { row -> | |||
| val code = row["itemCode"]?.toString()?.trim()?.uppercase().orEmpty() | |||
| if (code.isEmpty()) null | |||
| else code to row["printName"]?.toString() | |||
| } | |||
| } catch (e: DataAccessException) { | |||
| logger.warn("onpack_expiry_item_code printName list failed; falling back to codes only", e) | |||
| expiryItemCodes(machine).sorted().map { it to null } | |||
| } | |||
| } | |||
| private fun loadPrintName(machine: String, itemCode: String): String? { | |||
| return try { | |||
| jdbcDao.queryForString( | |||
| """ | |||
| SELECT printName FROM onpack_expiry_item_code | |||
| WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 | |||
| LIMIT 1 | |||
| """.trimIndent(), | |||
| mapOf("machine" to machine, "code" to itemCode), | |||
| ).trim().takeIf { it.isNotEmpty() } | |||
| } catch (_: DataAccessException) { | |||
| null | |||
| } | |||
| } | |||
| private fun enrichExpiryRows(machine: String, rows: List<Pair<String, String?>>): List<OnPackExpiryItemCodeDto> { | |||
| val codes = rows.map { it.first } | |||
| val items = if (codes.isEmpty()) emptyMap() | |||
| else itemsRepository.findByDeletedFalseAndCodeIn((codes + codes.map { it.lowercase() }).distinct()) | |||
| .mapNotNull { item -> | |||
| val code = item.code?.trim()?.uppercase().orEmpty() | |||
| if (code.isEmpty()) null else code to item | |||
| } | |||
| .toMap() | |||
| val shelf = itemDefaultShelfLifeService.findRowsByItemCodes(codes) | |||
| return rows.map { (code, printName) -> | |||
| val item = items[code] | |||
| val stockDesc = item?.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.udfudesc } | |||
| val defaultPrintName = PyJobOrderListMapper.buildDisplayItemName(item?.name, stockDesc) | |||
| val sl = shelf[code] | |||
| OnPackExpiryItemCodeDto( | |||
| machine = machine, | |||
| itemCode = code, | |||
| printName = printName?.trim()?.takeIf { it.isNotEmpty() }, | |||
| defaultPrintName = defaultPrintName, | |||
| defaultDays = sl?.defaultDays, | |||
| minus18Days = sl?.minus18Days, | |||
| useMinus18 = sl?.useMinus18 == true, | |||
| effectiveDays = sl?.let { ItemDefaultShelfLifeService.effectiveDays(it) }, | |||
| ) | |||
| } | |||
| } | |||
| private fun supportedItems(machine: String): List<OnPackSupportedItemDto> { | |||
| val registered = registeredCodes(machine) | |||
| val inDatabase = itemCodesWithImage(machine) | |||
| val builtin = builtinImageCodes(machine) | |||
| val expiry = if (machine == MACHINE_JUICE) expiryItemCodes(MACHINE_JUICE) else emptySet() | |||
| return mergeSupported(registered + expiry, 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.NgpclPushResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest | |||
| import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||
| @@ -56,10 +57,25 @@ import java.time.Duration | |||
| import java.time.Instant | |||
| import java.time.LocalDate | |||
| import java.time.ZoneId | |||
| import java.time.format.DateTimeFormatter | |||
| // Data class to store bitmap bytes + width (for XML) | |||
| data class BitmapResult(val bytes: ByteArray, val width: Int) | |||
| data class OnPackZipResult( | |||
| val bytes: ByteArray, | |||
| val skippedWithoutExpiry: List<String> = emptyList(), | |||
| ) | |||
| private data class OnPackBmpExportItem( | |||
| val codeLower: String, | |||
| val itemId: Long, | |||
| val stockInLineId: Long, | |||
| val itemCode: String, | |||
| val productName: String, | |||
| val planDate: LocalDate?, | |||
| ) | |||
| /** One Bag2-style laser TCP attempt (internal to [PlasticBagPrinterService]). */ | |||
| private data class LaserBag2TcpResult( | |||
| val success: Boolean, | |||
| @@ -75,10 +91,12 @@ class PlasticBagPrinterService( | |||
| private val jdbcDao: JdbcDao, | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| private val itemUomService: ItemUomService, | |||
| private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, | |||
| private val settingsService: SettingsService, | |||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | |||
| private val environment: Environment, | |||
| private val objectMapper: ObjectMapper, | |||
| private val onPackTemplateFileService: OnPackTemplateFileService, | |||
| ) { | |||
| private val logger = LoggerFactory.getLogger(javaClass) | |||
| private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong") | |||
| @@ -88,6 +106,70 @@ class PlasticBagPrinterService( | |||
| private const val DEFAULT_LASER_BAG2_PORT = 45678 | |||
| private const val DEFAULT_LASER_ITEM_CODES = "PP1175" | |||
| private const val PACKAGING_PROCESS_NAME = "包裝" | |||
| /** Designer product BMP canvas (LOGO), 1-bit. */ | |||
| private const val ONPACK_PRODUCT_BMP_WIDTH = 593 | |||
| private const val ONPACK_PRODUCT_BMP_HEIGHT = 90 | |||
| /** Designer item-code BMP canvas (LOGO_2), 1-bit. */ | |||
| private const val ONPACK_CODE_BMP_WIDTH = 385 | |||
| private const val ONPACK_CODE_BMP_HEIGHT = 141 | |||
| /** Designer production-date BMP canvas (LOGO_3), 1-bit. */ | |||
| private const val ONPACK_DATE_BMP_WIDTH = 505 | |||
| private const val ONPACK_DATE_BMP_HEIGHT = 141 | |||
| /** Designer expiry BMP canvas (LOGO_5), 1-bit. */ | |||
| private const val ONPACK_EXPIRY_BMP_WIDTH = 623 | |||
| private const val ONPACK_EXPIRY_BMP_HEIGHT = 79 | |||
| /** | |||
| * Laser TCP: `json;itemCode;itemName;;` or, when [expiryDate] is set, | |||
| * `json;itemCode;itemName;Expiry Date yyyyMMdd;;`. | |||
| * The lemon EZCAD job must bind a 4th text param or it replies invalid. | |||
| */ | |||
| 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;;" | |||
| } | |||
| } | |||
| fun laserAckLooksInvalid(ack: String?): Boolean = | |||
| ack?.contains("invalid", ignoreCase = true) == true | |||
| /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ | |||
| fun formatLaserExpiryParam(expiryDate: String?): String { | |||
| val raw = expiryDate?.trim().orEmpty() | |||
| if (raw.isEmpty()) return "" | |||
| if (raw.startsWith("Expiry Date ", ignoreCase = true)) { | |||
| return raw.replace(";", ",") | |||
| } | |||
| val iso = raw.take(10) | |||
| try { | |||
| return ItemDefaultShelfLifeService.formatPrintLabel(LocalDate.parse(iso)) | |||
| } catch (_: Exception) { | |||
| } | |||
| if (raw.length == 8 && raw.all { it.isDigit() }) { | |||
| try { | |||
| val d = LocalDate.parse(raw, DateTimeFormatter.BASIC_ISO_DATE) | |||
| return ItemDefaultShelfLifeService.formatPrintLabel(d) | |||
| } catch (_: Exception) { | |||
| } | |||
| } | |||
| return raw.replace(";", ",") | |||
| } | |||
| } | |||
| fun getLaserBag2Settings(): LaserBag2SettingsResponse { | |||
| @@ -176,8 +258,22 @@ class PlasticBagPrinterService( | |||
| } | |||
| val ids = filtered.mapNotNull { it.id } | |||
| val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | |||
| val printDate = ItemDefaultShelfLifeService.today() | |||
| val shelfLifeByCode = itemDefaultShelfLifeService.printShelfLifeByItemCodes( | |||
| filtered.map { it.bom?.item?.code ?: it.bom?.code }, | |||
| ) | |||
| return filtered.map { jo -> | |||
| PyJobOrderListMapper.toLaserListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) | |||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | |||
| val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(itemCode, shelfLifeByCode, printDate) | |||
| PyJobOrderListMapper.toLaserListItem( | |||
| jo, | |||
| printed[jo.id!!], | |||
| stockInLineRepository, | |||
| itemUomService, | |||
| defaultShelfLifeDays = days, | |||
| useMinus18 = useMinus18, | |||
| expiryDate = expiry, | |||
| ) | |||
| } | |||
| } | |||
| @@ -193,6 +289,18 @@ class PlasticBagPrinterService( | |||
| * Bag2.py [send_job_to_laser] / [send_job_to_laser_with_retry]: UTF-8 TCP payload and optional ack read. | |||
| */ | |||
| fun sendLaserBag2Job(request: LaserBag2SendRequest): LaserBag2SendResponse { | |||
| return try { | |||
| sendLaserBag2JobInner(request) | |||
| } catch (e: Exception) { | |||
| logger.error("sendLaserBag2Job unexpected error", e) | |||
| LaserBag2SendResponse( | |||
| success = false, | |||
| message = "送出失敗:${e.message ?: e.javaClass.simpleName}", | |||
| ) | |||
| } | |||
| } | |||
| private fun sendLaserBag2JobInner(request: LaserBag2SendRequest): LaserBag2SendResponse { | |||
| val ip = (request.printerIp?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: resolveLaserBag2Host()).trim() | |||
| val port = request.printerPort ?: resolveLaserBag2Port() | |||
| @@ -203,32 +311,28 @@ class PlasticBagPrinterService( | |||
| stockInLineId = request.stockInLineId, | |||
| itemCode = request.itemCode, | |||
| itemName = request.itemName, | |||
| expiryDate = request.expiryDate, | |||
| ) | |||
| val response = if (first.success) { | |||
| LaserBag2SendResponse( | |||
| success = true, | |||
| message = first.message, | |||
| payloadSent = first.payload, | |||
| printerAck = first.printerAck, | |||
| receiveAcknowledged = first.receiveAcknowledged, | |||
| ) | |||
| val chosen = if (first.success) { | |||
| first | |||
| } else { | |||
| val second = sendLaserBag2TcpOnce( | |||
| sendLaserBag2TcpOnce( | |||
| ip = ip, | |||
| port = port, | |||
| itemId = request.itemId, | |||
| stockInLineId = request.stockInLineId, | |||
| itemCode = request.itemCode, | |||
| itemName = request.itemName, | |||
| ) | |||
| LaserBag2SendResponse( | |||
| success = second.success, | |||
| message = second.message, | |||
| payloadSent = second.payload, | |||
| printerAck = second.printerAck, | |||
| receiveAcknowledged = second.receiveAcknowledged, | |||
| expiryDate = request.expiryDate, | |||
| ) | |||
| } | |||
| val response = LaserBag2SendResponse( | |||
| success = chosen.success, | |||
| message = chosen.message, | |||
| payloadSent = chosen.payload, | |||
| printerAck = chosen.printerAck, | |||
| receiveAcknowledged = chosen.receiveAcknowledged, | |||
| ) | |||
| if (response.success && response.receiveAcknowledged) { | |||
| try { | |||
| persistLaserLastReceiveSuccess(request, response.printerAck) | |||
| @@ -292,14 +396,9 @@ class PlasticBagPrinterService( | |||
| stockInLineId: Long?, | |||
| itemCode: String?, | |||
| itemName: String?, | |||
| expiryDate: String? = null, | |||
| ): LaserBag2TcpResult { | |||
| val codeStr = (itemCode ?: "").trim().replace(";", ",") | |||
| val nameStr = (itemName ?: "").trim().replace(";", ",") | |||
| val payload = if (itemId != null && stockInLineId != null) { | |||
| "{\"itemId\":$itemId,\"stockInLineId\":$stockInLineId};$codeStr;$nameStr;;" | |||
| } else { | |||
| "0;$codeStr;$nameStr;;" | |||
| } | |||
| val payload = buildLaserBag2Payload(itemId, stockInLineId, itemCode, itemName, expiryDate) | |||
| val bytes = payload.toByteArray(StandardCharsets.UTF_8) | |||
| var socket: Socket? = null | |||
| try { | |||
| @@ -346,6 +445,15 @@ class PlasticBagPrinterService( | |||
| } catch (_: SocketTimeoutException) { | |||
| // Same as Python Bag3: ignore read timeout, payload was still sent | |||
| } | |||
| if (laserAckLooksInvalid(ackRaw)) { | |||
| return LaserBag2TcpResult( | |||
| false, | |||
| "檸檬機回覆 invalid,未接受指令。", | |||
| payload, | |||
| ackRaw, | |||
| false, | |||
| ) | |||
| } | |||
| val msg = if (receiveAck) { | |||
| "已送出激光機:$payload(已確認)" | |||
| } else { | |||
| @@ -564,7 +672,18 @@ class PlasticBagPrinterService( | |||
| return baos.toByteArray() | |||
| } | |||
| fun generateOnPackQrZip(jobOrders: List<OnPackQrJobOrderRequest>): ByteArray { | |||
| /** | |||
| * 汁水機 OnPack: templates under classpath `onpack2030/{code}.image`. | |||
| * Always swaps LOGO_4 to the generated QR BMP. | |||
| * When [includeExpiry] is true, clones [OnPackPp1181Master] `.image` / `.job` for each code | |||
| * on the UI-managed `onpack_expiry_item_code` list, and generates product/code/date/expiry BMPs. | |||
| * Old ZIP callers must pass false; they keep per-code `onpack2030` templates unchanged. | |||
| */ | |||
| fun generateOnPackQrZip( | |||
| jobOrders: List<OnPackQrJobOrderRequest>, | |||
| includeExpiry: Boolean = false, | |||
| printDate: LocalDate? = null, | |||
| ): OnPackZipResult { | |||
| val normalizedJobOrders = jobOrders | |||
| .map { | |||
| OnPackQrJobOrderRequest( | |||
| @@ -600,6 +719,12 @@ class PlasticBagPrinterService( | |||
| val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds } | |||
| require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" } | |||
| val expiryPrintNames = if (includeExpiry) { | |||
| onPackTemplateFileService.expiryPrintNames(OnPackTemplateFileService.MACHINE_JUICE) | |||
| } else { | |||
| emptyMap() | |||
| } | |||
| val exportItemsRaw = packagingJobOrders | |||
| .groupBy { it.itemCode.trim().lowercase() } | |||
| .mapNotNull { (codeLower, orders) -> | |||
| @@ -608,31 +733,146 @@ class PlasticBagPrinterService( | |||
| ?: return@mapNotNull null | |||
| val itemId = stockInLine.item?.id ?: return@mapNotNull null | |||
| val stockInLineId = stockInLine.id ?: return@mapNotNull null | |||
| Triple(codeLower, itemId, stockInLineId) | |||
| val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null) | |||
| val baseName = jo?.bom?.name ?: stockInLine.item?.name | |||
| val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc | |||
| val computedName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc) | |||
| val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase() | |||
| val productName = expiryPrintNames[itemCode]?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: computedName | |||
| ?: itemCode | |||
| OnPackBmpExportItem( | |||
| codeLower, | |||
| itemId, | |||
| stockInLineId, | |||
| itemCode, | |||
| productName, | |||
| jo?.planStart?.toLocalDate(), | |||
| ) | |||
| } | |||
| require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } | |||
| val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet() | |||
| val allowedBmpCodes = codesOnPackMatchingTemplateType(codesUpper, "bmp") | |||
| val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.first.uppercase()) } | |||
| val codesUpper = exportItemsRaw.map { it.itemCode }.toSet() | |||
| val allowedBmpCodes = if (includeExpiry) { | |||
| onPackTemplateFileService.expiryItemCodes(OnPackTemplateFileService.MACHINE_JUICE) | |||
| } else { | |||
| codesOnPackMatchingTemplateType(codesUpper, "bmp") | |||
| } | |||
| val exportItemsListed = 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(exportItemsListed.isNotEmpty()) { | |||
| if (includeExpiry) { | |||
| "當日工單沒有在汁水機到期日 ZIP 品號清單中的項目(OnPack 模板可管理此清單)" | |||
| } else { | |||
| "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" | |||
| } | |||
| } | |||
| val effectivePrintDate = printDate | |||
| ?: exportItemsListed.firstNotNullOfOrNull { it.planDate } | |||
| ?: ItemDefaultShelfLifeService.today() | |||
| val skippedWithoutExpiry = mutableListOf<String>() | |||
| val exportItems = if (includeExpiry) { | |||
| exportItemsListed.filter { item -> | |||
| val label = itemDefaultShelfLifeService.expiryDatePrintLabel(item.itemCode, effectivePrintDate) | |||
| if (label.isNullOrBlank()) { | |||
| skippedWithoutExpiry += item.itemCode | |||
| false | |||
| } else { | |||
| true | |||
| } | |||
| } | |||
| } else { | |||
| exportItemsListed | |||
| } | |||
| require(exportItems.isNotEmpty()) { | |||
| if (includeExpiry && skippedWithoutExpiry.isNotEmpty()) { | |||
| "當日汁水機清單品號都沒有預設保質期,無法產生到期日 ZIP。請到設定 → 物品預設保質期新增:${skippedWithoutExpiry.joinToString("、")}" | |||
| } else if (includeExpiry) { | |||
| "當日工單沒有在汁水機到期日 ZIP 品號清單中的項目(OnPack 模板可管理此清單)" | |||
| } else { | |||
| "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" | |||
| } | |||
| } | |||
| val expiryMasterImage = if (includeExpiry) loadPp1181ExpiryMasterImage() else null | |||
| val expiryMasterJob = if (includeExpiry) loadPp1181MasterJob() else null | |||
| if (includeExpiry) { | |||
| require(expiryMasterImage != null) { "找不到 PP1181 到期日主模板(onpack2030_exp/pp1181.image)" } | |||
| } | |||
| val baos = ByteArrayOutputStream() | |||
| ZipOutputStream(baos).use { zos -> | |||
| val addedEntries = linkedSetOf<String>() | |||
| exportItems.forEach { (codeLower, itemId, stockInLineId) -> | |||
| val imageTemplate = loadOnPackImageTemplateOrNull(codeLower) ?: return@forEach | |||
| exportItems.forEach { item -> | |||
| val codeLower = item.codeLower | |||
| val imageTemplate = if (includeExpiry) { | |||
| val master = expiryMasterImage ?: return@forEach | |||
| OnPackPp1181Master.rewriteImageBytes(master, codeLower) | |||
| } else { | |||
| loadOnPackImageTemplateOrNull(codeLower, forExpiry = false) ?: return@forEach | |||
| } | |||
| val qrContent = """{"itemId": $itemId, "stockInLineId": $stockInLineId}""" | |||
| val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}""" | |||
| // Target approximately 470x389 BMP, but with larger visible QR and very little vertical whitespace. | |||
| // Width = 386 + (42 * 2) = 470 | |||
| // Height = 386 + (1 * 2) = 388 (~389) | |||
| val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1) | |||
| val qrBmpFileName = "${codeLower}qr.bmp" | |||
| val imageFileName = "$codeLower.image" | |||
| val imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName) | |||
| var imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName) | |||
| if (includeExpiry) { | |||
| val productFile = "${codeLower}Product.bmp" | |||
| val codeFile = "${codeLower}Code.bmp" | |||
| val dateFile = "${codeLower}Date.bmp" | |||
| val productBmp = createMonochromeBitmapFixed( | |||
| item.productName, | |||
| ONPACK_PRODUCT_BMP_WIDTH, | |||
| ONPACK_PRODUCT_BMP_HEIGHT, | |||
| ) | |||
| val codeBmp = createMonochromeBitmapFixed( | |||
| item.itemCode, | |||
| ONPACK_CODE_BMP_WIDTH, | |||
| ONPACK_CODE_BMP_HEIGHT, | |||
| ) | |||
| val dateBmp = createMonochromeBitmapFixed( | |||
| ItemDefaultShelfLifeService.formatProductionDatePrintLabel(effectivePrintDate), | |||
| ONPACK_DATE_BMP_WIDTH, | |||
| ONPACK_DATE_BMP_HEIGHT, | |||
| ) | |||
| imageContent = withOnPackLogoFile(imageContent, "LOGO", productFile) | |||
| imageContent = withOnPackLogoFile(imageContent, "LOGO_2", codeFile) | |||
| imageContent = withOnPackLogoFile(imageContent, "LOGO_3", dateFile) | |||
| if (addedEntries.add(productFile)) { | |||
| addToZip(zos, productFile, productBmp.bytes) | |||
| } | |||
| if (addedEntries.add(codeFile)) { | |||
| addToZip(zos, codeFile, codeBmp.bytes) | |||
| } | |||
| if (addedEntries.add(dateFile)) { | |||
| addToZip(zos, dateFile, dateBmp.bytes) | |||
| } | |||
| } | |||
| val expiryLabel = if (includeExpiry) { | |||
| itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate) | |||
| } else { | |||
| null | |||
| } | |||
| if (!expiryLabel.isNullOrBlank()) { | |||
| val expBmp = createMonochromeBitmapFixed( | |||
| expiryLabel, | |||
| ONPACK_EXPIRY_BMP_WIDTH, | |||
| ONPACK_EXPIRY_BMP_HEIGHT, | |||
| ) | |||
| val expBmpFileName = "${codeLower}exp.bmp" | |||
| imageContent = withOnPackExpiryLogo(imageContent, expBmpFileName, expBmp.width) | |||
| if (addedEntries.add(expBmpFileName)) { | |||
| addToZip(zos, expBmpFileName, expBmp.bytes) | |||
| } | |||
| } | |||
| if (addedEntries.add(qrBmpFileName)) { | |||
| addToZip(zos, qrBmpFileName, bmp.bytes) | |||
| @@ -640,19 +880,50 @@ class PlasticBagPrinterService( | |||
| if (addedEntries.add(imageFileName)) { | |||
| addToZip(zos, imageFileName, imageContent) | |||
| } | |||
| if (!includeExpiry) { | |||
| 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" | |||
| if (includeExpiry) { | |||
| val masterJob = expiryMasterJob | |||
| if (masterJob != null && addedEntries.add(jobFileName)) { | |||
| addToZip(zos, jobFileName, OnPackPp1181Master.rewriteJobBytes(masterJob, imageFileName)) | |||
| } | |||
| } else { | |||
| 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" } | |||
| } | |||
| return baos.toByteArray() | |||
| return OnPackZipResult(baos.toByteArray(), skippedWithoutExpiry.distinct()) | |||
| } | |||
| /** | |||
| * OnPack2023 檸檬機: templates under classpath `onpack2030_2/{code}.image` with embedded QR (Static text). | |||
| * Only replaces `<Text>...</Text>` under `<Static type="StaticSrc.V1">` with JSON payload. No separate .bmp in zip. | |||
| * When [includeExpiry] is true, also injects TEXT_EXP (does not change TEXT_3 production date). Old ZIP callers must pass false. | |||
| */ | |||
| fun generateOnPackQrTextZip(jobOrders: List<OnPackQrJobOrderRequest>): ByteArray { | |||
| fun generateOnPackQrTextZip( | |||
| jobOrders: List<OnPackQrJobOrderRequest>, | |||
| includeExpiry: Boolean = false, | |||
| printDate: LocalDate? = null, | |||
| ): OnPackZipResult { | |||
| val normalizedJobOrders = jobOrders | |||
| .map { | |||
| OnPackQrJobOrderRequest( | |||
| @@ -702,20 +973,56 @@ class PlasticBagPrinterService( | |||
| val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet() | |||
| val allowedTextCodes = codesOnPackMatchingTemplateType(codesUpper, "text") | |||
| val exportItems = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) } | |||
| val exportItemsListed = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) } | |||
| require(exportItemsListed.isNotEmpty()) { "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" } | |||
| val effectivePrintDate = printDate ?: ItemDefaultShelfLifeService.today() | |||
| val skippedWithoutExpiry = mutableListOf<String>() | |||
| val exportItems = if (includeExpiry) { | |||
| exportItemsListed.filter { (codeLower, _, _) -> | |||
| val code = codeLower.uppercase() | |||
| val label = itemDefaultShelfLifeService.expiryDatePrintLabel(code, effectivePrintDate) | |||
| if (label.isNullOrBlank()) { | |||
| skippedWithoutExpiry += code | |||
| false | |||
| } else { | |||
| true | |||
| } | |||
| } | |||
| } else { | |||
| exportItemsListed | |||
| } | |||
| require(exportItems.isNotEmpty()) { "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" } | |||
| require(exportItems.isNotEmpty()) { | |||
| if (includeExpiry && skippedWithoutExpiry.isNotEmpty()) { | |||
| "當日檸檬機品號都沒有預設保質期,無法產生到期日 ZIP。請到設定 → 物品預設保質期新增:${skippedWithoutExpiry.joinToString("、")}" | |||
| } else { | |||
| "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" | |||
| } | |||
| } | |||
| val baos = ByteArrayOutputStream() | |||
| ZipOutputStream(baos).use { zos -> | |||
| val addedEntries = linkedSetOf<String>() | |||
| exportItems.forEach { (codeLower, itemId, stockInLineId) -> | |||
| val imageTemplate = loadOnPack2030_2ImageTemplateOrNull(codeLower) ?: run { | |||
| logger.warn("OnPack text ZIP: missing classpath template onpack2030_2/{}.image", codeLower.uppercase()) | |||
| logger.warn("OnPack text ZIP: missing template for {}", codeLower.uppercase()) | |||
| return@forEach | |||
| } | |||
| val imageFileName = "${codeLower.uppercase()}.image" | |||
| val imageContent = withOnPackStaticQrText(codeLower, imageTemplate, itemId, stockInLineId) | |||
| val expiryLabel = if (includeExpiry) { | |||
| itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate) | |||
| } else { | |||
| null | |||
| } | |||
| val imageContent = withOnPackStaticQrText( | |||
| codeLower, | |||
| imageTemplate, | |||
| itemId, | |||
| stockInLineId, | |||
| expiryCompact = expiryLabel, | |||
| ) | |||
| if (addedEntries.add(imageFileName)) { | |||
| addToZip(zos, imageFileName, imageContent) | |||
| } | |||
| @@ -723,7 +1030,7 @@ class PlasticBagPrinterService( | |||
| extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> | |||
| if (!addedEntries.add(bmpName)) return@forEach | |||
| val bmpBytes = loadOnPack2030_2AssetOrNull(bmpName) ?: run { | |||
| logger.warn("OnPack text ZIP: missing classpath asset onpack2030_2/{}", bmpName) | |||
| logger.warn("OnPack text ZIP: missing asset {}", bmpName) | |||
| return@forEach | |||
| } | |||
| addToZip(zos, bmpName, bmpBytes) | |||
| @@ -737,7 +1044,7 @@ class PlasticBagPrinterService( | |||
| } | |||
| require(addedEntries.isNotEmpty()) { "No OnPack text template files could be generated for the selected date" } | |||
| } | |||
| return baos.toByteArray() | |||
| return OnPackZipResult(baos.toByteArray(), skippedWithoutExpiry.distinct()) | |||
| } | |||
| /** | |||
| @@ -753,7 +1060,7 @@ class PlasticBagPrinterService( | |||
| ) | |||
| } | |||
| val zipBytes = try { | |||
| generateOnPackQrTextZip(jobOrders) | |||
| generateOnPackQrTextZip(jobOrders).bytes | |||
| } catch (e: Exception) { | |||
| logger.warn("OnPack text ZIP generation failed before NGPCL push", e) | |||
| return NgpclPushResponse( | |||
| @@ -816,10 +1123,46 @@ class PlasticBagPrinterService( | |||
| else -> return emptySet() | |||
| } | |||
| val rows = jdbcDao.queryForList(sql, mapOf("codes" to codesUpper.toList())) | |||
| return rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet() | |||
| val fromTable = rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet() | |||
| val machine = if (normalizedType == "text") { | |||
| OnPackTemplateFileService.MACHINE_LEMON | |||
| } else { | |||
| OnPackTemplateFileService.MACHINE_JUICE | |||
| } | |||
| val fromDbFiles = onPackTemplateFileService.itemCodesWithImage(machine) | |||
| .filter { it in codesUpper } | |||
| .toSet() | |||
| return fromTable + fromDbFiles | |||
| } | |||
| private fun loadPp1181ExpiryMasterImage(): ByteArray? { | |||
| listOf(OnPackPp1181Master.MASTER_IMAGE, OnPackPp1181Master.MASTER_IMAGE_FALLBACK).forEach { path -> | |||
| val resource = ClassPathResource(path) | |||
| if (resource.exists()) { | |||
| return resource.inputStream.use { it.readBytes() } | |||
| } | |||
| } | |||
| logger.warn("Missing PP1181 expiry master image on classpath") | |||
| return null | |||
| } | |||
| private fun loadPp1181MasterJob(): ByteArray? { | |||
| val resource = ClassPathResource(OnPackPp1181Master.MASTER_JOB) | |||
| if (!resource.exists()) { | |||
| logger.warn("Missing PP1181 master job on classpath") | |||
| return null | |||
| } | |||
| return resource.inputStream.use { it.readBytes() } | |||
| } | |||
| private fun loadOnPackImageTemplateOrNull(codeLower: String): ByteArray? { | |||
| private fun loadOnPackImageTemplateOrNull(codeLower: String, forExpiry: Boolean = false): ByteArray? { | |||
| onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_JUICE, codeLower)?.let { return it } | |||
| if (forExpiry) { | |||
| val expiryResource = ClassPathResource("onpack2030_exp/${codeLower}.image") | |||
| if (expiryResource.exists()) { | |||
| return expiryResource.inputStream.use { it.readBytes() } | |||
| } | |||
| } | |||
| val resourcePath = "onpack2030/${codeLower}.image" | |||
| val resource = ClassPathResource(resourcePath) | |||
| if (!resource.exists()) return null | |||
| @@ -828,6 +1171,7 @@ class PlasticBagPrinterService( | |||
| /** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */ | |||
| private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? { | |||
| onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_LEMON, codeLower)?.let { return it } | |||
| val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image" | |||
| val resource = ClassPathResource(resourcePath) | |||
| if (!resource.exists()) return null | |||
| @@ -836,11 +1180,24 @@ class PlasticBagPrinterService( | |||
| private fun loadOnPack2030_2AssetOrNull(fileName: String): ByteArray? { | |||
| val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null } | |||
| onPackTemplateFileService.loadAsset(OnPackTemplateFileService.MACHINE_LEMON, safe)?.let { return it } | |||
| val resource = ClassPathResource("onpack2030_2/$safe") | |||
| if (!resource.exists()) return null | |||
| return resource.inputStream.use { it.readBytes() } | |||
| } | |||
| private fun loadJuiceAssetOrNull(fileName: String): ByteArray? { | |||
| val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null } | |||
| onPackTemplateFileService.loadAsset(OnPackTemplateFileService.MACHINE_JUICE, safe)?.let { return it } | |||
| listOf("onpack2030_exp/$safe", "onpack2030/$safe").forEach { path -> | |||
| val resource = ClassPathResource(path) | |||
| if (resource.exists()) { | |||
| return resource.inputStream.use { it.readBytes() } | |||
| } | |||
| } | |||
| return null | |||
| } | |||
| /** Collect `<FileName>xxx.bmp</FileName>` inside each `<Logo>...</Logo>` block (decoded template XML). */ | |||
| private fun extractLogoBmpFileNamesFromOnPackImageXml(xml: String): Set<String> { | |||
| val out = linkedSetOf<String>() | |||
| @@ -853,14 +1210,17 @@ class PlasticBagPrinterService( | |||
| } | |||
| private fun withOnPackLogo4Bmp(imageBytes: ByteArray, qrBmpFileName: String): ByteArray { | |||
| // Use ISO-8859-1 one-byte mapping so all original bytes are preserved, | |||
| // while replacing only ASCII XML fragment for LOGO_4 filename. | |||
| val oneByteText = String(imageBytes, StandardCharsets.ISO_8859_1) | |||
| val replaced = oneByteText.replace( | |||
| Regex("""(<Name>\s*LOGO_4\s*</Name>[\s\S]*?<FileName>)([^<]+)(</FileName>)"""), | |||
| "$1$qrBmpFileName$3", | |||
| ) | |||
| return replaced.toByteArray(StandardCharsets.ISO_8859_1) | |||
| return withOnPackLogoFile(imageBytes, "LOGO_4", qrBmpFileName) | |||
| } | |||
| private fun withOnPackLogoFile(imageBytes: ByteArray, fieldName: String, bmpFileName: String): ByteArray { | |||
| val (xml, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) | |||
| return encodeBack(OnPackJuiceExpiryXml.rewriteLogoFileName(xml, fieldName, bmpFileName)) | |||
| } | |||
| private fun withOnPackExpiryLogo(imageBytes: ByteArray, bmpFileName: String, bmpWidth: Int): ByteArray { | |||
| val (xml, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) | |||
| return encodeBack(OnPackJuiceExpiryXml.applyExpiry(xml, bmpFileName, bmpWidth)) | |||
| } | |||
| /** | |||
| @@ -868,39 +1228,16 @@ class PlasticBagPrinterService( | |||
| * substring/regex matching (`hasNameQr=false` while XML is valid). | |||
| */ | |||
| private fun decodeOnPackImageTemplateForTextEdit(bytes: ByteArray): Pair<String, (String) -> ByteArray> { | |||
| val bomUtf16Le = byteArrayOf(0xFF.toByte(), 0xFE.toByte()) | |||
| val bomUtf16Be = byteArrayOf(0xFE.toByte(), 0xFF.toByte()) | |||
| val bomUtf8 = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) | |||
| when { | |||
| bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] -> { | |||
| val body = bytes.copyOfRange(2, bytes.size) | |||
| val text = String(body, StandardCharsets.UTF_16LE) | |||
| return text to { s -> bomUtf16Le + s.toByteArray(StandardCharsets.UTF_16LE) } | |||
| } | |||
| bytes.size >= 2 && bytes[0] == bomUtf16Be[0] && bytes[1] == bomUtf16Be[1] -> { | |||
| val body = bytes.copyOfRange(2, bytes.size) | |||
| val text = String(body, StandardCharsets.UTF_16BE) | |||
| return text to { s -> bomUtf16Be + s.toByteArray(StandardCharsets.UTF_16BE) } | |||
| } | |||
| bytes.size >= 3 && bytes[0] == bomUtf8[0] && bytes[1] == bomUtf8[1] && bytes[2] == bomUtf8[2] -> { | |||
| val body = bytes.copyOfRange(3, bytes.size) | |||
| val text = String(body, StandardCharsets.UTF_8) | |||
| return text to { s -> bomUtf8 + s.toByteArray(StandardCharsets.UTF_8) } | |||
| } | |||
| // UTF-16 LE without BOM: "<" == 0x3C 0x00 | |||
| bytes.size >= 2 && bytes[0] == 0x3C.toByte() && bytes[1] == 0x00.toByte() -> { | |||
| val text = String(bytes, StandardCharsets.UTF_16LE) | |||
| return text to { s -> s.toByteArray(StandardCharsets.UTF_16LE) } | |||
| } | |||
| else -> { | |||
| val utf8 = String(bytes, StandardCharsets.UTF_8) | |||
| return utf8 to { s -> s.toByteArray(StandardCharsets.UTF_8) } | |||
| } | |||
| } | |||
| return OnPackImageTemplateCodec.decode(bytes) | |||
| } | |||
| private fun withOnPackStaticQrText(forCode: String, imageBytes: ByteArray, itemId: Long, stockInLineId: Long): ByteArray { | |||
| private fun withOnPackStaticQrText( | |||
| forCode: String, | |||
| imageBytes: ByteArray, | |||
| itemId: Long, | |||
| stockInLineId: Long, | |||
| expiryCompact: String? = null, | |||
| ): ByteArray { | |||
| val payload = """{"itemId": $itemId, "stockInLineId": $stockInLineId}""" | |||
| val (oneByteText, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) | |||
| // Must NOT use Static[^>]*StaticSrc — [^>]* already eats type='StaticSrc.V1', so StaticSrc can never match. | |||
| @@ -934,7 +1271,12 @@ class PlasticBagPrinterService( | |||
| ) | |||
| } | |||
| val withQrCellWidth = replaceQrV1CellWidth67To50(replaced) | |||
| return encodeBack(withQrCellWidth) | |||
| val withExpiry = if (expiryCompact.isNullOrBlank()) { | |||
| withQrCellWidth | |||
| } else { | |||
| OnPackLemonExpiryXml.applyExpiry(withQrCellWidth, expiryCompact) | |||
| } | |||
| return encodeBack(withExpiry) | |||
| } | |||
| /** First `<CellWidth>67</CellWidth>` inside `<QR type='QR.V1'>` → 50 (export tuning). */ | |||
| @@ -952,6 +1294,33 @@ class PlasticBagPrinterService( | |||
| return if (collapsed.length <= maxLen) collapsed else collapsed.take(maxLen) + "…" | |||
| } | |||
| /** 1-bit BMP at an exact canvas (no crop). Used for OnPack expiry so it matches the designer slot. */ | |||
| private fun createMonochromeBitmapFixed(text: String, width: Int, height: Int): BitmapResult { | |||
| val img = BufferedImage(width, height, BufferedImage.TYPE_BYTE_BINARY) | |||
| img.createGraphics().apply { | |||
| color = Color.WHITE | |||
| fillRect(0, 0, width, height) | |||
| color = Color.BLACK | |||
| setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF) | |||
| var fontSize = (height * 0.92).toInt().coerceAtLeast(1) | |||
| var font = Font("SimSun", Font.BOLD, fontSize) | |||
| var metrics = getFontMetrics(font) | |||
| while (fontSize > 8 && metrics.stringWidth(text) > width) { | |||
| fontSize-- | |||
| font = Font("SimSun", Font.BOLD, fontSize) | |||
| metrics = getFontMetrics(font) | |||
| } | |||
| this.font = font | |||
| val x = 0 | |||
| val y = metrics.ascent + ((height - metrics.height) / 2).coerceAtLeast(0) | |||
| drawString(text, x, y) | |||
| dispose() | |||
| } | |||
| val baos = ByteArrayOutputStream() | |||
| ImageIO.write(img, "bmp", baos) | |||
| return BitmapResult(baos.toByteArray(), img.width) | |||
| } | |||
| private fun createMonochromeBitmap(text: String, targetHeight: Int): BitmapResult { | |||
| // Step 1: Measure text width with temporary image | |||
| val tempImg = BufferedImage(1, 1, BufferedImage.TYPE_BYTE_BINARY) | |||
| @@ -0,0 +1,92 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.web | |||
| import com.ffii.fpsms.modules.jobOrder.service.OnPackTemplateFileService | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeDto | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeRequest | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeUpdateRequest | |||
| 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.PutMapping | |||
| import org.springframework.web.bind.annotation.RequestBody | |||
| 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() | |||
| } | |||
| /** Item codes included in 汁水機 OnPack expiry ZIP (dynamic PP1181 template). */ | |||
| @GetMapping("/expiry-codes") | |||
| fun listExpiryCodes( | |||
| @RequestParam(required = false) machine: String?, | |||
| ): List<OnPackExpiryItemCodeDto> { | |||
| return onPackTemplateFileService.listExpiryItemCodes(machine) | |||
| } | |||
| @PostMapping("/expiry-codes") | |||
| fun addExpiryCode(@RequestBody body: OnPackExpiryItemCodeRequest): OnPackExpiryItemCodeDto { | |||
| return onPackTemplateFileService.addExpiryItemCode(body.machine, body.itemCode) | |||
| } | |||
| @PutMapping("/expiry-codes") | |||
| fun updateExpiryCode(@RequestBody body: OnPackExpiryItemCodeUpdateRequest): OnPackExpiryItemCodeDto { | |||
| return onPackTemplateFileService.updateExpiryItemCode(body) | |||
| } | |||
| @DeleteMapping("/expiry-codes") | |||
| fun deleteExpiryCode( | |||
| @RequestParam(required = false) machine: String?, | |||
| @RequestParam itemCode: String, | |||
| ): ResponseEntity<Void> { | |||
| onPackTemplateFileService.removeExpiryItemCode(machine, itemCode) | |||
| return ResponseEntity.noContent().build() | |||
| } | |||
| @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"))) | |||
| } | |||
| @ExceptionHandler(IllegalStateException::class) | |||
| fun unavailable(e: IllegalStateException): ResponseEntity<Map<String, String>> { | |||
| return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(mapOf("message" to (e.message ?: "Unavailable"))) | |||
| } | |||
| } | |||
| @@ -1,6 +1,7 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.web | |||
| import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService | |||
| import com.ffii.fpsms.modules.jobOrder.service.OnPackZipResult | |||
| import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | |||
| @@ -60,11 +61,20 @@ class PlasticBagPrinterController( | |||
| } | |||
| /** | |||
| * Bag2.py laser TCP protocol: `{"itemId":n,"stockInLineId":m};code;name;;` or `0;code;name;;` | |||
| * Bag2/Bag4 laser TCP: `{"itemId":n,"stockInLineId":m};code;name;;` or | |||
| * `{"itemId":n,"stockInLineId":m};code;name;Expiry Date yyyyMMdd;;` when expiryDate is set. | |||
| */ | |||
| @PostMapping("/print-laser-bag2") | |||
| fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity<LaserBag2SendResponse> { | |||
| val resp = plasticBagPrinterService.sendLaserBag2Job(request) | |||
| val resp = try { | |||
| plasticBagPrinterService.sendLaserBag2Job(request) | |||
| } catch (e: Exception) { | |||
| logger.error("print-laser-bag2 failed", e) | |||
| LaserBag2SendResponse( | |||
| success = false, | |||
| message = "送出失敗:${e.message ?: e.javaClass.simpleName}", | |||
| ) | |||
| } | |||
| return if (resp.success) { | |||
| ResponseEntity.ok(resp) | |||
| } else { | |||
| @@ -118,15 +128,8 @@ class PlasticBagPrinterController( | |||
| response: HttpServletResponse, | |||
| ) { | |||
| try { | |||
| val zipBytes = plasticBagPrinterService.generateOnPackQrZip(request.jobOrders) | |||
| response.contentType = "application/zip" | |||
| response.setHeader( | |||
| HttpHeaders.CONTENT_DISPOSITION, | |||
| "attachment; filename=\"onpack_qr_codes.zip\"" | |||
| ) | |||
| response.setContentLength(zipBytes.size) | |||
| response.outputStream.write(zipBytes) | |||
| response.outputStream.flush() | |||
| val zip = plasticBagPrinterService.generateOnPackQrZip(request.jobOrders) | |||
| writeOnPackZip(response, "onpack_qr_codes.zip", zip) | |||
| } catch (e: IllegalArgumentException) { | |||
| response.status = HttpServletResponse.SC_BAD_REQUEST | |||
| response.contentType = "text/plain;charset=UTF-8" | |||
| @@ -148,6 +151,44 @@ class PlasticBagPrinterController( | |||
| } | |||
| } | |||
| /** | |||
| * Same 汁水機 ZIP as [downloadOnPackQr], plus expiry BMP from item_default_shelf_life. | |||
| * Clones PP1181 `.image` / `.job` for each code on `onpack_expiry_item_code`. | |||
| * Old [downloadOnPackQr] is unchanged. | |||
| */ | |||
| @PostMapping("/download-onpack-qr-with-expiry") | |||
| fun downloadOnPackQrWithExpiry( | |||
| @RequestBody request: OnPackQrDownloadRequest, | |||
| response: HttpServletResponse, | |||
| ) { | |||
| try { | |||
| val zip = plasticBagPrinterService.generateOnPackQrZip( | |||
| request.jobOrders, | |||
| includeExpiry = true, | |||
| printDate = request.planDate, | |||
| ) | |||
| writeOnPackZip(response, "onpack_qr_exp.zip", zip) | |||
| } catch (e: IllegalArgumentException) { | |||
| response.status = HttpServletResponse.SC_BAD_REQUEST | |||
| response.contentType = "text/plain;charset=UTF-8" | |||
| response.writer.write(e.message ?: "Invalid request") | |||
| response.writer.flush() | |||
| } catch (e: Exception) { | |||
| logger.error("POST /plastic/download-onpack-qr-with-expiry failed", e) | |||
| try { | |||
| if (!response.isCommitted) { | |||
| response.reset() | |||
| } | |||
| } catch (_: Exception) { | |||
| /* ignore */ | |||
| } | |||
| response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR | |||
| response.contentType = "text/plain;charset=UTF-8" | |||
| response.writer.write(e.message ?: "Download failed") | |||
| response.writer.flush() | |||
| } | |||
| } | |||
| /** OnPack2023 檸檬機: `onpack2030_2` templates with embedded QR (Static text only; no separate .bmp). */ | |||
| @PostMapping("/download-onpack-qr-text") | |||
| fun downloadOnPackQrText( | |||
| @@ -155,15 +196,8 @@ class PlasticBagPrinterController( | |||
| response: HttpServletResponse, | |||
| ) { | |||
| try { | |||
| val zipBytes = plasticBagPrinterService.generateOnPackQrTextZip(request.jobOrders) | |||
| response.contentType = "application/zip" | |||
| response.setHeader( | |||
| HttpHeaders.CONTENT_DISPOSITION, | |||
| "attachment; filename=\"onpack2023_lemon_qr.zip\"" | |||
| ) | |||
| response.setContentLength(zipBytes.size) | |||
| response.outputStream.write(zipBytes) | |||
| response.outputStream.flush() | |||
| val zip = plasticBagPrinterService.generateOnPackQrTextZip(request.jobOrders) | |||
| writeOnPackZip(response, "onpack2023_lemon_qr.zip", zip) | |||
| } catch (e: IllegalArgumentException) { | |||
| response.status = HttpServletResponse.SC_BAD_REQUEST | |||
| response.contentType = "text/plain;charset=UTF-8" | |||
| @@ -194,6 +228,43 @@ class PlasticBagPrinterController( | |||
| return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders)) | |||
| } | |||
| /** | |||
| * Same lemon OnPack ZIP as [downloadOnPackQrText], plus TEXT_EXP from item_default_shelf_life. | |||
| * Does not replace TEXT_3 (production / print date). Old [downloadOnPackQrText] is unchanged. | |||
| */ | |||
| @PostMapping("/download-onpack-qr-text-with-expiry") | |||
| fun downloadOnPackQrTextWithExpiry( | |||
| @RequestBody request: OnPackQrDownloadRequest, | |||
| response: HttpServletResponse, | |||
| ) { | |||
| try { | |||
| val zip = plasticBagPrinterService.generateOnPackQrTextZip( | |||
| request.jobOrders, | |||
| includeExpiry = true, | |||
| printDate = request.planDate, | |||
| ) | |||
| writeOnPackZip(response, "onpack2023_lemon_qr_exp.zip", zip) | |||
| } catch (e: IllegalArgumentException) { | |||
| response.status = HttpServletResponse.SC_BAD_REQUEST | |||
| response.contentType = "text/plain;charset=UTF-8" | |||
| response.writer.write(e.message ?: "Invalid request") | |||
| response.writer.flush() | |||
| } catch (e: Exception) { | |||
| logger.error("POST /plastic/download-onpack-qr-text-with-expiry failed", e) | |||
| try { | |||
| if (!response.isCommitted) { | |||
| response.reset() | |||
| } | |||
| } catch (_: Exception) { | |||
| /* ignore */ | |||
| } | |||
| response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR | |||
| response.contentType = "text/plain;charset=UTF-8" | |||
| response.writer.write(e.message ?: "Download failed") | |||
| response.writer.flush() | |||
| } | |||
| } | |||
| /** | |||
| * Test API to generate and download the printer job files as a ZIP. | |||
| * ONPACK2030 | |||
| @@ -315,4 +386,15 @@ class PlasticBagPrinterController( | |||
| } | |||
| } | |||
| private fun writeOnPackZip(response: HttpServletResponse, filename: String, zip: OnPackZipResult) { | |||
| if (zip.skippedWithoutExpiry.isNotEmpty()) { | |||
| response.setHeader("X-OnPack-Skipped-Expiry", zip.skippedWithoutExpiry.joinToString(",")) | |||
| } | |||
| response.contentType = "application/zip" | |||
| response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"") | |||
| response.setContentLength(zip.bytes.size) | |||
| response.outputStream.write(zip.bytes) | |||
| response.outputStream.flush() | |||
| } | |||
| } | |||
| @@ -0,0 +1,33 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.web.model | |||
| import com.fasterxml.jackson.core.JsonParser | |||
| import com.fasterxml.jackson.core.JsonToken | |||
| import com.fasterxml.jackson.databind.DeserializationContext | |||
| import com.fasterxml.jackson.databind.JsonDeserializer | |||
| /** | |||
| * Accepts `yyyy-MM-dd`, compact `yyyyMMdd`, `Expiry Date yyyyMMdd`, | |||
| * or Jackson date-array `[2026,8,27]` (happens when `@EnableWebMvc` uses a raw ObjectMapper). | |||
| */ | |||
| class FlexibleExpiryDateDeserializer : JsonDeserializer<String>() { | |||
| override fun deserialize(p: JsonParser, ctxt: DeserializationContext): String? { | |||
| return when (p.currentToken) { | |||
| JsonToken.VALUE_NULL -> null | |||
| JsonToken.VALUE_STRING -> p.valueAsString?.trim()?.takeIf { it.isNotEmpty() } | |||
| JsonToken.VALUE_NUMBER_INT -> p.valueAsString | |||
| JsonToken.START_ARRAY -> { | |||
| val y = p.nextIntValue(0) | |||
| val m = p.nextIntValue(0) | |||
| val d = p.nextIntValue(0) | |||
| while (p.nextToken() != JsonToken.END_ARRAY && p.currentToken != null) { | |||
| // skip extra tokens (e.g. nanoseconds on a datetime array) | |||
| } | |||
| if (y <= 0 || m <= 0 || d <= 0) null else "%04d-%02d-%02d".format(y, m, d) | |||
| } | |||
| else -> { | |||
| p.skipChildren() | |||
| null | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -1,12 +1,18 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.web.model | |||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties | |||
| import com.fasterxml.jackson.databind.annotation.JsonDeserialize | |||
| /** | |||
| * 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). | |||
| * The lemon EZCAD job must bind the 4th `;` field or it replies invalid. | |||
| * 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] | |||
| * when the printer returns a receive ack. | |||
| */ | |||
| @JsonIgnoreProperties(ignoreUnknown = true) | |||
| data class LaserBag2SendRequest( | |||
| val itemId: Long? = null, | |||
| val stockInLineId: Long? = null, | |||
| @@ -17,6 +23,12 @@ data class LaserBag2SendRequest( | |||
| val jobOrderId: Long? = null, | |||
| val jobOrderNo: String? = null, | |||
| val lotNo: String? = null, | |||
| /** | |||
| * Print-time expiry from the job-order list (`yyyy-MM-dd`, compact `yyyyMMdd`, | |||
| * Jackson `[yyyy,M,d]`, or already `Expiry Date yyyyMMdd`). Sent as the 4th TCP field. | |||
| */ | |||
| @JsonDeserialize(using = FlexibleExpiryDateDeserializer::class) | |||
| val expiryDate: String? = null, | |||
| /** AUTO (auto-send) or MANUAL (/laserPrint); optional. */ | |||
| val source: String? = null, | |||
| ) | |||
| @@ -0,0 +1,53 @@ | |||
| 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>, | |||
| ) | |||
| data class OnPackExpiryItemCodeDto( | |||
| val machine: String, | |||
| val itemCode: String, | |||
| val printName: String? = null, | |||
| val defaultPrintName: String? = null, | |||
| val defaultDays: Int? = null, | |||
| val minus18Days: Int? = null, | |||
| val useMinus18: Boolean = false, | |||
| val effectiveDays: Int? = null, | |||
| ) | |||
| data class OnPackExpiryItemCodeRequest( | |||
| val itemCode: String, | |||
| val machine: String? = "juice", | |||
| ) | |||
| data class OnPackExpiryItemCodeUpdateRequest( | |||
| val itemCode: String, | |||
| val machine: String? = "juice", | |||
| /** Empty string clears the override (use default name + unit). Omitted = leave unchanged. */ | |||
| val printName: String? = null, | |||
| val useMinus18: Boolean? = null, | |||
| ) | |||
| @@ -52,6 +52,8 @@ data class PrinterStatusResponse( | |||
| data class OnPackQrDownloadRequest( | |||
| val jobOrders: List<OnPackQrJobOrderRequest>, | |||
| /** /bagPrint filter date (job plan date). Used by expiry ZIP for LOGO_3 production date. */ | |||
| val planDate: java.time.LocalDate? = null, | |||
| ) | |||
| data class OnPackQrJobOrderRequest( | |||
| @@ -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,18 @@ | |||
| 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> | |||
| fun findByItemCodeIgnoreCase(itemCode: String): ItemDefaultShelfLife? | |||
| fun findByIdAndDeletedFalse(id: Long): ItemDefaultShelfLife? | |||
| fun findAllByDeletedFalseOrderByItemCodeAsc(): List<ItemDefaultShelfLife> | |||
| } | |||
| @@ -18,6 +18,7 @@ interface ItemsRepository : AbstractRepository<Items, Long> { | |||
| fun findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?; | |||
| fun findByCodeAndDeletedFalse(code: String): Items?; | |||
| fun findByDeletedFalseAndCodeIn(codes: Collection<String>): List<Items> | |||
| fun findByNameAndDeletedFalse(name: String): Items?; | |||
| fun findByM18IdAndDeletedIsFalse(m18Id: Long): Items?; | |||
| @@ -75,6 +75,7 @@ open class BomService( | |||
| ) { | |||
| companion object { | |||
| private const val BOM_WIP_DESCRIPTION = "WIP" | |||
| private const val BOM_DETAIL_EXPORT_TEMPLATE = "excelTemplate/bom_import_blank.xlsx" | |||
| } | |||
| open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse { | |||
| @@ -1127,7 +1128,8 @@ open class BomService( | |||
| } | |||
| 3 -> { | |||
| val equipmentName = tempCell.stringCellValue.trim() | |||
| if (equipmentName != "不適用") { | |||
| // 不合用 / 不適用:不掛 equipment(與格式檢查同等處理) | |||
| if (!isNotApplicableEquipment(equipmentName)) { | |||
| val equipment = bomGetOrCreateEquipment(equipmentName) | |||
| // println("equipment created") | |||
| bomProcessRequest.equipment = equipment | |||
| @@ -1913,6 +1915,314 @@ open class BomService( | |||
| } | |||
| } | |||
| /** Fill blank import template from a saved BOM version (BOM 明細 → 匯出 Excel). */ | |||
| @Transactional(readOnly = true) | |||
| open fun exportBomDetailExcel(id: Long): BomImportExportCorrectedResult { | |||
| val detail = getBomDetail(id) | |||
| val resource = ClassPathResource(BOM_DETAIL_EXPORT_TEMPLATE) | |||
| if (!resource.exists()) { | |||
| throw BadRequestException("BOM export template not found: $BOM_DETAIL_EXPORT_TEMPLATE") | |||
| } | |||
| resource.inputStream.use { input -> | |||
| val workbook: Workbook = XSSFWorkbook(input) | |||
| try { | |||
| val sheet = resolveImportBomSheet(workbook) | |||
| fillBomDetailOntoBlankTemplate(workbook, detail) | |||
| recalculateMaterialDerivedColumns(sheet) | |||
| refreshOutputUomFormulaDependents(sheet) | |||
| evaluateBlankTemplateFormulaDependents(sheet) | |||
| val bytes = ByteArrayOutputStream().use { out -> | |||
| workbook.write(out) | |||
| out.toByteArray() | |||
| } | |||
| val code = detail.itemCode?.trim().orEmpty().ifEmpty { "BOM" } | |||
| val rev = detail.revisionNo ?: 1 | |||
| return BomImportExportCorrectedResult( | |||
| bytes = bytes, | |||
| downloadFileName = "${code}_V${rev}.xlsx", | |||
| ) | |||
| } finally { | |||
| workbook.close() | |||
| } | |||
| } | |||
| } | |||
| /** | |||
| * Source cells on bom_import_blank.xlsx (食物成品): | |||
| * R5 name, R6 code (A2=R6, E2=R5), J2 kind, B4 stock qty, W5 stock unit code, S6 version. | |||
| */ | |||
| private fun fillBomDetailOntoBlankTemplate(workbook: Workbook, detail: BomDetailResponse) { | |||
| val sheet = resolveImportBomSheet(workbook) | |||
| detail.itemName?.trim()?.takeIf { it.isNotEmpty() }?.let { | |||
| getOrCreateCell(sheet, 4, 17).setCellValue(it) | |||
| } | |||
| detail.itemCode?.trim()?.takeIf { it.isNotEmpty() }?.let { | |||
| getOrCreateCell(sheet, 5, 17).setCellValue(it) | |||
| } | |||
| val kind = detail.bomKind?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: detail.description?.trim()?.takeIf { it.isNotEmpty() } | |||
| kind?.let { getOrCreateCell(sheet, 1, 9).setCellValue(it) } | |||
| val rev = detail.revisionNo ?: 1 | |||
| getOrCreateCell(sheet, 5, 18).setCellValue("V$rev") | |||
| val stockQty = detail.outputQtyStock ?: detail.outputQty | |||
| stockQty?.let { | |||
| getOrCreateCell(sheet, 3, 1).setCellValue(it.toDouble()) | |||
| } | |||
| val stockUnitCode = detail.itemId | |||
| ?.let { itemUomService.findStockUnitByItemId(it)?.uom?.code?.trim() } | |||
| ?.takeIf { it.isNotEmpty() } | |||
| stockUnitCode?.let { | |||
| getOrCreateCell(sheet, 4, 22).setCellValue(it) | |||
| } | |||
| detail.isDark?.let { setBasicInfoScaleByHeaderContains(sheet, "深淺", it) } | |||
| detail.isFloat?.let { setBasicInfoScaleByHeaderContains(sheet, "浮沉", it) } | |||
| detail.isDense?.let { setBasicInfoScaleByHeaderContains(sheet, "濃淡", it) } | |||
| detail.scrapRate?.let { setBasicInfoScaleByHeaderContains(sheet, "損耗率", it) } | |||
| detail.timeSequence?.let { setBasicInfoScaleByHeaderContains(sheet, "生產時段先後數值", it) } | |||
| detail.complexity?.let { setBasicInfoScaleByHeaderContains(sheet, "複雜度", it) } | |||
| detail.allergicSubstances?.let { setBasicInfoAllergicSubstances(sheet, it) } | |||
| fillDbMaterialsOntoBlankTemplate(sheet, detail) | |||
| fillDbProcessesOntoBlankTemplate(sheet, detail.processes) | |||
| restoreSellingPriceHeaderFormulas(workbook) | |||
| fillProductRecipeSheet(workbook, detail) | |||
| } | |||
| /** Selling Price B1/B2/B3 follow 食物成品 name/code/version (original Excel formulas). */ | |||
| private fun restoreSellingPriceHeaderFormulas(workbook: Workbook) { | |||
| val selling = workbook.getSheet("Selling Price (售價)") ?: return | |||
| setCellFormulaIfBlank(selling, 0, 1, "'食物成品 '!R5") | |||
| setCellFormulaIfBlank(selling, 1, 1, "'食物成品 '!A2") | |||
| setCellFormulaIfBlank(selling, 2, 1, "'食物成品 '!N1") | |||
| } | |||
| private fun setCellFormulaIfBlank(sheet: Sheet, rowIdx: Int, colIdx: Int, formula: String) { | |||
| val cell = getOrCreateCell(sheet, rowIdx, colIdx) | |||
| if (cell.cellType == CellType.FORMULA) return | |||
| if (cell.cellType != CellType.BLANK && readStringCellValue(cell) != null) return | |||
| cell.cellFormula = formula | |||
| } | |||
| /** | |||
| * 產品製方: title + version; materials fill 主要成份 then 其他汁料 (20+20); | |||
| * 製作方式 filled sequentially (A=1..n, B=description). Brand columns left empty. | |||
| */ | |||
| private fun fillProductRecipeSheet(workbook: Workbook, detail: BomDetailResponse) { | |||
| val sheet = workbook.getSheet("產品製方") ?: return | |||
| val code = detail.itemCode?.trim().orEmpty() | |||
| val name = detail.itemName?.trim().orEmpty() | |||
| val title = when { | |||
| code.isNotEmpty() && name.isNotEmpty() -> "產品名稱: ${code}_${name}" | |||
| name.isNotEmpty() -> "產品名稱: $name" | |||
| code.isNotEmpty() -> "產品名稱: $code" | |||
| else -> null | |||
| } | |||
| title?.let { getOrCreateCell(sheet, 3, 0).setCellValue(it) } | |||
| val rev = detail.revisionNo ?: 1 | |||
| getOrCreateCell(sheet, 3, 10).setCellValue("V$rev") | |||
| fillProductRecipePackWeight(sheet, detail) | |||
| detail.outputQtyStock?.let { | |||
| getOrCreateCell(sheet, 7, 8).setCellValue(it.toDouble()) | |||
| } | |||
| val leftNameCol = 1 | |||
| val leftQtyCol = 3 | |||
| val leftUomCol = 4 | |||
| val rightNameCol = 8 | |||
| val rightQtyCol = 10 | |||
| val rightUomCol = 11 | |||
| val leftStart = 14 | |||
| val rightStart = 14 | |||
| val slotsPerSide = 20 | |||
| detail.materials.forEachIndexed { index, material -> | |||
| val itemName = material.itemName?.trim()?.takeIf { it.isNotEmpty() } ?: return@forEachIndexed | |||
| val uom = material.recipeUom?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: material.recipeUomId?.let { uomConversionRepository.findById(it).orElse(null)?.code?.trim() } | |||
| val qty = material.recipeQty | |||
| if (index < slotsPerSide) { | |||
| val rowIdx = leftStart + index | |||
| getOrCreateCell(sheet, rowIdx, leftNameCol).setCellValue(itemName) | |||
| qty?.let { getOrCreateCell(sheet, rowIdx, leftQtyCol).setCellValue(it.toDouble()) } | |||
| uom?.let { getOrCreateCell(sheet, rowIdx, leftUomCol).setCellValue(it) } | |||
| } else { | |||
| val rowIdx = rightStart + (index - slotsPerSide) | |||
| if (index - slotsPerSide >= slotsPerSide) return@forEachIndexed | |||
| getOrCreateCell(sheet, rowIdx, rightNameCol).setCellValue(itemName) | |||
| qty?.let { getOrCreateCell(sheet, rowIdx, rightQtyCol).setCellValue(it.toDouble()) } | |||
| uom?.let { getOrCreateCell(sheet, rowIdx, rightUomCol).setCellValue(it) } | |||
| } | |||
| } | |||
| val processStart = findProductRecipeProcessStartRow(sheet) | |||
| val processEnd = processStart + 12 | |||
| val processes = detail.processes.sortedBy { it.seqNo ?: Long.MAX_VALUE } | |||
| for (rowIdx in processStart..processEnd) { | |||
| val i = rowIdx - processStart | |||
| getOrCreateCell(sheet, rowIdx, 0).setCellValue((i + 1).toDouble()) | |||
| val process = processes.getOrNull(i) | |||
| val text = process?.processDescription?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: process?.processName?.trim()?.takeIf { it.isNotEmpty() } | |||
| if (text != null) { | |||
| getOrCreateCell(sheet, rowIdx, 1).setCellValue(text) | |||
| } else { | |||
| clearCellValue(sheet, rowIdx, 1) | |||
| } | |||
| } | |||
| } | |||
| /** I7 成品每包重量 = 1 stock unit converted to item base unit. */ | |||
| private fun fillProductRecipePackWeight(sheet: Sheet, detail: BomDetailResponse) { | |||
| val itemId = detail.itemId ?: return | |||
| val stockUom = itemUomService.findStockUnitByItemId(itemId)?.uom ?: return | |||
| val stockUomId = stockUom.id ?: return | |||
| val packWeight = runCatching { | |||
| itemUomService.convertQtyToBaseQtyPrecise(itemId, stockUomId, BigDecimal.ONE) | |||
| }.getOrNull() ?: return | |||
| getOrCreateCell(sheet, 6, 8).setCellValue(packWeight.toDouble()) | |||
| val baseCode = itemUomService.findBaseUnitByItemId(itemId)?.uom?.code?.trim()?.takeIf { it.isNotEmpty() } | |||
| if (baseCode != null && readStringCellValue(sheet.getRow(6)?.getCell(9)) == null) { | |||
| getOrCreateCell(sheet, 6, 9).setCellValue(baseCode) | |||
| } | |||
| } | |||
| private fun findProductRecipeProcessStartRow(sheet: Sheet): Int { | |||
| for (r in 30..45) { | |||
| val v = readStringCellValue(sheet.getRow(r)?.getCell(0)) ?: continue | |||
| if (v.contains("製作方式")) return r + 1 | |||
| } | |||
| return 37 | |||
| } | |||
| private fun fillDbMaterialsOntoBlankTemplate(sheet: Sheet, detail: BomDetailResponse) { | |||
| val headerRowIndex = findMaterialHeaderRowIndex(sheet) ?: return | |||
| val styleRowIdx = headerRowIndex + 1 | |||
| val seqByProcessId = detail.processes.mapNotNull { p -> | |||
| val id = p.id ?: return@mapNotNull null | |||
| val seq = p.seqNo ?: return@mapNotNull null | |||
| id to seq | |||
| }.toMap() | |||
| detail.materials.forEachIndexed { index, material -> | |||
| val rowIdx = headerRowIndex + 1 + index | |||
| val itemCode = material.itemCode?.trim().orEmpty() | |||
| if (itemCode.isNotEmpty()) { | |||
| getOrCreateStyledCell(sheet, rowIdx, 0, styleRowIdx).setCellValue(itemCode) | |||
| getOrCreateStyledCell(sheet, rowIdx, 16, styleRowIdx).setCellValue(itemCode) | |||
| } | |||
| material.itemName?.trim()?.takeIf { it.isNotEmpty() }?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 1, styleRowIdx).setCellValue(it) | |||
| getOrCreateStyledCell(sheet, rowIdx, 17, styleRowIdx).setCellValue(it) | |||
| } | |||
| material.recipeQty?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 2, styleRowIdx).setCellValue(it.toDouble()) | |||
| } | |||
| val uomCode = material.recipeUomId | |||
| ?.let { uomConversionRepository.findById(it).orElse(null)?.code?.trim() } | |||
| ?.takeIf { it.isNotEmpty() } | |||
| ?: material.recipeUom?.trim()?.takeIf { it.isNotEmpty() } | |||
| uomCode?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 3, styleRowIdx).setCellValue(it) | |||
| } | |||
| val joinSeq = material.processStepIds.mapNotNull { seqByProcessId[it] }.minOrNull() | |||
| joinSeq?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 10, styleRowIdx).setCellValue(it.toDouble()) | |||
| } | |||
| getOrCreateStyledCell(sheet, rowIdx, 15, styleRowIdx).setCellValue((index + 1).toDouble()) | |||
| } | |||
| } | |||
| private fun fillDbProcessesOntoBlankTemplate(sheet: Sheet, processes: List<BomProcessDto>) { | |||
| val startRowIndex = findProcessDataStartRowIndex(sheet) ?: return | |||
| val styleRowIdx = startRowIndex | |||
| processes.sortedBy { it.seqNo ?: Long.MAX_VALUE }.forEachIndexed { index, process -> | |||
| val rowIdx = startRowIndex + index | |||
| process.seqNo?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 0, styleRowIdx).setCellValue(it.toDouble()) | |||
| } | |||
| val processLabel = process.processName?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: process.processCode?.trim()?.takeIf { it.isNotEmpty() } | |||
| processLabel?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 1, styleRowIdx).setCellValue(it) | |||
| } | |||
| process.processDescription?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 2, styleRowIdx).setCellValue(it) | |||
| } | |||
| val equipmentText = formatProcessEquipmentForExcel( | |||
| BomImportPreviewProcessLine( | |||
| equipmentDescription = process.equipmentDescription, | |||
| equipmentName = process.equipmentName, | |||
| ), | |||
| ) ?: "不適用" | |||
| getOrCreateStyledCell(sheet, rowIdx, 3, styleRowIdx).setCellValue(equipmentText) | |||
| process.durationInMinute?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 5, styleRowIdx).setCellValue(it.toDouble()) | |||
| } | |||
| process.byProduct?.trim()?.takeIf { it.isNotEmpty() }?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 8, styleRowIdx).setCellValue(it) | |||
| } | |||
| process.byProductUom?.trim()?.takeIf { it.isNotEmpty() }?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 10, styleRowIdx).setCellValue(it) | |||
| } | |||
| process.prepTimeInMinute?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 11, styleRowIdx).setCellValue(it.toDouble()) | |||
| } | |||
| process.postProdTimeInMinute?.let { | |||
| getOrCreateStyledCell(sheet, rowIdx, 12, styleRowIdx).setCellValue(it.toDouble()) | |||
| } | |||
| } | |||
| } | |||
| private fun findProcessDataStartRowIndex(sheet: Sheet): Int? { | |||
| var startRowIndex = 30 | |||
| val maxHeaderSearch = 70 | |||
| var headerFound = false | |||
| while (startRowIndex < maxHeaderSearch) { | |||
| val cell = sheet.getRow(startRowIndex)?.getCell(0) | |||
| if (cell != null && cell.cellType == CellType.STRING && cell.stringCellValue.trim() == "工序") { | |||
| headerFound = true | |||
| startRowIndex += 2 | |||
| break | |||
| } | |||
| startRowIndex++ | |||
| } | |||
| return if (headerFound) startRowIndex else null | |||
| } | |||
| private fun getOrCreateStyledCell( | |||
| sheet: Sheet, | |||
| rowIdx: Int, | |||
| colIdx: Int, | |||
| styleRowIdx: Int, | |||
| ): org.apache.poi.ss.usermodel.Cell { | |||
| val row = sheet.getRow(rowIdx) ?: sheet.createRow(rowIdx).also { created -> | |||
| sheet.getRow(styleRowIdx)?.let { created.height = it.height } | |||
| } | |||
| val existing = row.getCell(colIdx) | |||
| if (existing != null) return existing | |||
| val created = row.createCell(colIdx) | |||
| sheet.getRow(styleRowIdx)?.getCell(colIdx)?.cellStyle?.let { created.cellStyle = it } | |||
| return created | |||
| } | |||
| private fun evaluateBlankTemplateFormulaDependents(sheet: Sheet) { | |||
| val workbook = sheet.workbook | |||
| val evaluator = workbook.creationHelper.createFormulaEvaluator() | |||
| val cells = mutableListOf( | |||
| sheet.getRow(1)?.getCell(0), // A2 =R6 | |||
| sheet.getRow(1)?.getCell(4), // E2 =R5 | |||
| sheet.getRow(0)?.getCell(13), // N1 =S6 | |||
| ) | |||
| val selling = workbook.getSheet("Selling Price (售價)") | |||
| if (selling != null) { | |||
| cells += selling.getRow(0)?.getCell(1) // B1 ='食物成品 '!R5 | |||
| cells += selling.getRow(1)?.getCell(1) // B2 ='食物成品 '!A2 | |||
| cells += selling.getRow(2)?.getCell(1) // B3 ='食物成品 '!N1 | |||
| } | |||
| for (cell in cells) { | |||
| if (cell != null && cell.cellType == CellType.FORMULA) { | |||
| evaluator.evaluateFormulaCell(cell) | |||
| } | |||
| } | |||
| } | |||
| private fun resolveImportBomFilePath(batchId: String, fileName: String): Path? { | |||
| val path = getBatchDir(batchId).resolve(fileName) | |||
| return if (Files.exists(path)) path else null | |||
| @@ -2467,11 +2777,17 @@ open class BomService( | |||
| } | |||
| return lines | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 63 | v1.0.0 | 2026-08-10 */ | |||
| /** BOM 工序「使用設備」為不合用/不適用時,不掛 equipment FK */ | |||
| private fun isNotApplicableEquipment(value: String): Boolean { | |||
| val trimmed = value.trim() | |||
| return trimmed == "不合用" || trimmed == "不適用" | |||
| } | |||
| private fun isValidEquipmentType(value: String): Boolean { | |||
| val trimmed = value.trim() | |||
| if (trimmed.isEmpty()) return false | |||
| if (trimmed == "不合用" || trimmed == "不適用") return true | |||
| if (isNotApplicableEquipment(trimmed)) return true | |||
| if (trimmed.contains(",")) return false // 新增:不允許逗號 | |||
| val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機 | |||
| return regex.matches(trimmed) | |||
| @@ -3508,8 +3824,11 @@ for (r in 0..20) { | |||
| processCode = p.process?.code, | |||
| processName = p.process?.name, | |||
| processDescription = p.description, | |||
| byProduct = p.byProduct, | |||
| byProductUom = p.byProductUom, | |||
| equipmentCode = p.equipment?.code, | |||
| equipmentName = p.equipment?.name, | |||
| equipmentDescription = p.equipment?.description, | |||
| durationInMinute = p.durationInMinute, | |||
| prepTimeInMinute = p.prepTimeInMinute, | |||
| postProdTimeInMinute = p.postProdTimeInMinute, | |||
| @@ -0,0 +1,242 @@ | |||
| package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLife | |||
| import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLifeRepository | |||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | |||
| import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRequest | |||
| import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRow | |||
| import org.springframework.http.HttpStatus | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.transaction.annotation.Transactional | |||
| import org.springframework.web.server.ResponseStatusException | |||
| 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 | |||
| open class ItemDefaultShelfLifeService( | |||
| private val repository: ItemDefaultShelfLifeRepository, | |||
| private val itemsRepository: ItemsRepository, | |||
| ) { | |||
| open 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() | |||
| } | |||
| open fun defaultDaysByItemCodes(codes: Collection<String?>): Map<String, Int> = | |||
| printShelfLifeByItemCodes(codes).mapValues { it.value.effectiveDays } | |||
| open 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) | |||
| } | |||
| open fun findRowsByItemCodes(codes: Collection<String?>): Map<String, ItemDefaultShelfLife> { | |||
| 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() | |||
| if (code.isEmpty()) null else code to row | |||
| } | |||
| .toMap() | |||
| } | |||
| @Transactional | |||
| open fun setUseMinus18(itemCode: String?, useMinus18: Boolean): ItemDefaultShelfLifeRow { | |||
| val code = normalizeItemCode(itemCode) | |||
| if (code.isEmpty()) { | |||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code is required") | |||
| } | |||
| val row = repository.findByDeletedFalseAndItemCodeIgnoreCase(code) | |||
| ?: throw ResponseStatusException( | |||
| HttpStatus.BAD_REQUEST, | |||
| "找不到 $code 的預設保質期。請先到設定 → 物品預設保質期新增。", | |||
| ) | |||
| row.useMinus18 = useMinus18 | |||
| return toRow(repository.save(row)) | |||
| } | |||
| open fun expiryDate(itemCode: String?, printDate: LocalDate = today()): LocalDate? { | |||
| val days = defaultDays(itemCode) ?: return null | |||
| return expiryOn(printDate, days) | |||
| } | |||
| open fun expiryDateIso(itemCode: String?, printDate: LocalDate = today()): String? = | |||
| expiryDate(itemCode, printDate)?.toString() | |||
| open fun expiryDateCompact(itemCode: String?, printDate: LocalDate = today()): String? = | |||
| expiryDate(itemCode, printDate)?.format(COMPACT) | |||
| /** Printed bag wording, e.g. `Expiry Date 20260821`. */ | |||
| open fun expiryDatePrintLabel(itemCode: String?, printDate: LocalDate = today()): String? = | |||
| expiryDate(itemCode, printDate)?.let { formatPrintLabel(it) } | |||
| open fun list(q: String? = null): List<ItemDefaultShelfLifeRow> { | |||
| val rows = repository.findAllByDeletedFalseOrderByItemCodeAsc() | |||
| val names = itemNamesByCode(rows.mapNotNull { it.itemCode }) | |||
| val needle = q?.trim()?.lowercase().orEmpty() | |||
| return rows | |||
| .map { toRow(it, names[it.itemCode?.trim()?.uppercase().orEmpty()]) } | |||
| .filter { row -> | |||
| if (needle.isEmpty()) true | |||
| else row.itemCode.lowercase().contains(needle) || | |||
| row.itemName.orEmpty().lowercase().contains(needle) || | |||
| row.remarks.orEmpty().lowercase().contains(needle) | |||
| } | |||
| } | |||
| @Transactional | |||
| open fun create(request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { | |||
| val code = normalizeItemCode(request.itemCode) | |||
| validateRequest(request, code) | |||
| val existing = repository.findByItemCodeIgnoreCase(code) | |||
| if (existing != null && existing.deleted != true) { | |||
| throw ResponseStatusException(HttpStatus.CONFLICT, "Item code already exists: $code") | |||
| } | |||
| val row = existing ?: ItemDefaultShelfLife() | |||
| applyRequest(row, request, code) | |||
| row.deleted = false | |||
| return toRow(repository.save(row)) | |||
| } | |||
| @Transactional | |||
| open fun update(id: Long, request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { | |||
| val row = repository.findByIdAndDeletedFalse(id) | |||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Shelf life row $id not found") | |||
| val code = normalizeItemCode(request.itemCode) | |||
| validateRequest(request, code) | |||
| val other = repository.findByItemCodeIgnoreCase(code) | |||
| if (other != null && other.id != id) { | |||
| throw ResponseStatusException(HttpStatus.CONFLICT, "Item code already exists: $code") | |||
| } | |||
| applyRequest(row, request, code) | |||
| return toRow(repository.save(row)) | |||
| } | |||
| @Transactional | |||
| open fun markDeleted(id: Long): List<ItemDefaultShelfLifeRow> { | |||
| val row = repository.findByIdAndDeletedFalse(id) | |||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Shelf life row $id not found") | |||
| row.deleted = true | |||
| repository.save(row) | |||
| return list() | |||
| } | |||
| private fun applyRequest(row: ItemDefaultShelfLife, request: ItemDefaultShelfLifeRequest, code: String) { | |||
| row.itemCode = code | |||
| row.defaultDays = request.defaultDays | |||
| row.minus18Days = request.minus18Days | |||
| row.useMinus18 = request.useMinus18 == true | |||
| row.openedDays = request.openedDays | |||
| row.storageC = request.storageC?.trim()?.takeIf { it.isNotEmpty() } | |||
| row.remarks = request.remarks?.trim()?.takeIf { it.isNotEmpty() } | |||
| } | |||
| private fun toRow(row: ItemDefaultShelfLife, itemName: String? = null): ItemDefaultShelfLifeRow { | |||
| val code = row.itemCode?.trim().orEmpty() | |||
| val name = itemName ?: itemNameFor(code) | |||
| return ItemDefaultShelfLifeRow( | |||
| id = row.id ?: 0L, | |||
| itemCode = code, | |||
| itemName = name, | |||
| defaultDays = row.defaultDays, | |||
| minus18Days = row.minus18Days, | |||
| useMinus18 = row.useMinus18 == true, | |||
| openedDays = row.openedDays, | |||
| storageC = row.storageC, | |||
| remarks = row.remarks, | |||
| effectiveDays = effectiveDays(row), | |||
| ) | |||
| } | |||
| private fun itemNameFor(code: String): String? { | |||
| if (code.isEmpty()) return null | |||
| return itemsRepository.findByCodeAndDeletedFalse(code)?.name?.trim()?.takeIf { it.isNotEmpty() } | |||
| } | |||
| private fun itemNamesByCode(codes: Collection<String>): Map<String, String> { | |||
| val raw = codes.mapNotNull { it.trim().takeIf { c -> c.isNotEmpty() } }.distinct() | |||
| if (raw.isEmpty()) return emptyMap() | |||
| val lookup = (raw + raw.map { it.uppercase() }).distinct() | |||
| return itemsRepository.findByDeletedFalseAndCodeIn(lookup) | |||
| .mapNotNull { item -> | |||
| val code = item.code?.trim()?.uppercase().orEmpty() | |||
| val name = item.name?.trim()?.takeIf { it.isNotEmpty() } | |||
| if (code.isEmpty() || name == null) null else code to name | |||
| } | |||
| .toMap() | |||
| } | |||
| 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 } | |||
| } | |||
| fun normalizeItemCode(raw: String?): String = | |||
| raw?.trim()?.uppercase().orEmpty() | |||
| fun validateRequest(request: ItemDefaultShelfLifeRequest, code: String) { | |||
| if (code.isEmpty()) { | |||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code is required") | |||
| } | |||
| if (code.length > 50) { | |||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code must be at most 50 characters") | |||
| } | |||
| requireDays("defaultDays", request.defaultDays) | |||
| requireDays("minus18Days", request.minus18Days) | |||
| requireDays("openedDays", request.openedDays) | |||
| val storage = request.storageC?.trim().orEmpty() | |||
| if (storage.length > 20) { | |||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "storageC must be at most 20 characters") | |||
| } | |||
| val remarks = request.remarks?.trim().orEmpty() | |||
| if (remarks.length > 255) { | |||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "remarks must be at most 255 characters") | |||
| } | |||
| } | |||
| private fun requireDays(field: String, value: Int?) { | |||
| if (value != null && value < 0) { | |||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$field must be 0 or greater") | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -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 | |||
| } | |||
| } | |||
| @@ -634,6 +634,10 @@ open fun listBagItemsCombo(): List<Map<String, String>> { | |||
| return itemsRepository.findByCodeAndDeletedFalse(code); | |||
| } | |||
| open fun findByCodeAndType(code: String, type: String): Items? { | |||
| return itemsRepository.findByCodeAndTypeAndDeletedFalse(code, type) | |||
| } | |||
| open fun findByM18Id(m18Id: Long): Items? { | |||
| return itemsRepository.findByM18IdAndDeletedIsFalse(m18Id) | |||
| } | |||
| @@ -703,34 +707,67 @@ open fun listBagItemsCombo(): List<Map<String, String>> { | |||
| @Transactional | |||
| open fun saveItem(request: NewItemRequest): MessageResponse { | |||
| val duplicatedItem = itemsRepository.findByCodeAndTypeAndDeletedFalse(request.code, request.type) | |||
| val ownerOfNewM18Id = request.m18Id?.let { findByM18Id(it) } | |||
| if (duplicatedItem != null && duplicatedItem.id != request.id) { | |||
| if (request.m18Id != null && request.id == null && duplicatedItem.m18Id == null) { | |||
| duplicatedItem.m18Id = request.m18Id | |||
| duplicatedItem.m18LastModifyDate = request.m18LastModifyDate | |||
| val linked = itemsRepository.saveAndFlush(duplicatedItem) | |||
| val canLink = ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( | |||
| requestId = request.id, | |||
| requestM18Id = request.m18Id, | |||
| duplicatedItemId = duplicatedItem.id, | |||
| ownerOfNewM18IdItemId = ownerOfNewM18Id?.id, | |||
| ) | |||
| if (!canLink) { | |||
| return MessageResponse( | |||
| id = linked.id, | |||
| code = linked.code, | |||
| name = linked.name, | |||
| type = linked.type.toString(), | |||
| message = "Linked m18Id to existing item with same code", | |||
| errorPosition = null, | |||
| id = request.id ?: duplicatedItem.id, | |||
| code = request.code, | |||
| name = request.name, | |||
| type = request.type.toString(), | |||
| message = "The item code has already existed", | |||
| errorPosition = "code" | |||
| ) | |||
| } | |||
| // M18 recode: attach new proId only. Do not overwrite name/QC/UOM via this path. | |||
| if (duplicatedItem.m18Id != request.m18Id) { | |||
| logger.warn( | |||
| "Remapping item m18Id: localId=${duplicatedItem.id} code=${duplicatedItem.code} oldM18Id=${duplicatedItem.m18Id} newM18Id=${request.m18Id}" | |||
| ) | |||
| } | |||
| duplicatedItem.m18Id = request.m18Id | |||
| duplicatedItem.m18LastModifyDate = request.m18LastModifyDate ?: duplicatedItem.m18LastModifyDate | |||
| val linked = itemsRepository.saveAndFlush(duplicatedItem) | |||
| return MessageResponse( | |||
| id = linked.id, | |||
| code = linked.code, | |||
| name = linked.name, | |||
| type = linked.type.toString(), | |||
| message = ItemM18IdRemapSupport.LINKED_MESSAGE, | |||
| errorPosition = null, | |||
| ) | |||
| } | |||
| // Prefer local id so an existing item can have its m18Id updated from the UI. | |||
| val item = when { | |||
| request.id != null && request.id > 0 -> | |||
| itemsRepository.findByIdAndDeletedFalse(request.id) ?: Items() | |||
| ownerOfNewM18Id != null -> ownerOfNewM18Id | |||
| else -> Items() | |||
| } | |||
| logger.info("item: $item") | |||
| if (ownerOfNewM18Id != null && ownerOfNewM18Id.id != item.id) { | |||
| return MessageResponse( | |||
| id = request.id ?: duplicatedItem.id, | |||
| id = request.id ?: item.id, | |||
| code = request.code, | |||
| name = request.name, | |||
| type = request.type.toString(), | |||
| message = "The item code has already existed", | |||
| errorPosition = "code" | |||
| message = "M18 ID already used by another item", | |||
| errorPosition = "m18Id" | |||
| ) | |||
| } | |||
| val item = if (request.m18Id != null) findByM18Id(request.m18Id) ?: Items() | |||
| else if (request.id != null && request.id > 0) itemsRepository.findByIdAndDeletedFalse(request.id) ?: Items() | |||
| else Items() | |||
| logger.info("item: $item") | |||
| if (item.m18LastModifyDate == request.m18LastModifyDate) { | |||
| val m18IdUnchanged = request.m18Id == null || item.m18Id == request.m18Id | |||
| // Skip only for unchanged M18 pulls (request carries lastModifyDate). UI saves do not. | |||
| if (request.m18LastModifyDate != null && | |||
| item.id != null && | |||
| item.m18LastModifyDate == request.m18LastModifyDate && | |||
| m18IdUnchanged | |||
| ) { | |||
| return MessageResponse( | |||
| id = item.id, | |||
| code = item.code, | |||
| @@ -242,6 +242,15 @@ fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | |||
| return bomService.getBomDetail(id) | |||
| } | |||
| @GetMapping("/{id}/export-excel") | |||
| fun exportBomDetailExcel(@PathVariable id: Long): ResponseEntity<Resource> { | |||
| val result = bomService.exportBomDetailExcel(id) | |||
| return ResponseEntity.ok() | |||
| .header(HttpHeaders.CONTENT_DISPOSITION, "attachment") | |||
| .header(HttpHeaders.CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") | |||
| .body(ByteArrayResource(result.bytes)) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 17 | v1.0.0 | 2026-07-16 */ | |||
| @PostMapping("/{id}/activate-version") | |||
| fun activateBomVersion(@PathVariable id: Long): BomDetailResponse { | |||
| @@ -0,0 +1,64 @@ | |||
| package com.ffii.fpsms.modules.master.web | |||
| import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService | |||
| import org.springframework.web.bind.annotation.DeleteMapping | |||
| 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.PutMapping | |||
| import org.springframework.web.bind.annotation.RequestBody | |||
| import org.springframework.web.bind.annotation.RequestMapping | |||
| import org.springframework.web.bind.annotation.RequestParam | |||
| import org.springframework.web.bind.annotation.RestController | |||
| @RequestMapping("itemDefaultShelfLives") | |||
| @RestController | |||
| class ItemDefaultShelfLifeController( | |||
| private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, | |||
| ) { | |||
| @GetMapping | |||
| fun list(@RequestParam(required = false) q: String?): List<ItemDefaultShelfLifeRow> { | |||
| return itemDefaultShelfLifeService.list(q) | |||
| } | |||
| @PostMapping | |||
| fun create(@RequestBody request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { | |||
| return itemDefaultShelfLifeService.create(request) | |||
| } | |||
| @PutMapping("/{id}") | |||
| fun update( | |||
| @PathVariable id: Long, | |||
| @RequestBody request: ItemDefaultShelfLifeRequest, | |||
| ): ItemDefaultShelfLifeRow { | |||
| return itemDefaultShelfLifeService.update(id, request) | |||
| } | |||
| @DeleteMapping("/{id}") | |||
| fun delete(@PathVariable id: Long): List<ItemDefaultShelfLifeRow> { | |||
| return itemDefaultShelfLifeService.markDeleted(id) | |||
| } | |||
| } | |||
| data class ItemDefaultShelfLifeRequest( | |||
| val itemCode: String? = null, | |||
| val defaultDays: Int? = null, | |||
| val minus18Days: Int? = null, | |||
| val useMinus18: Boolean? = false, | |||
| val openedDays: Int? = null, | |||
| val storageC: String? = null, | |||
| val remarks: String? = null, | |||
| ) | |||
| data class ItemDefaultShelfLifeRow( | |||
| val id: Long, | |||
| val itemCode: String, | |||
| val itemName: String? = null, | |||
| val defaultDays: Int? = null, | |||
| val minus18Days: Int? = null, | |||
| val useMinus18: Boolean = false, | |||
| val openedDays: Int? = null, | |||
| val storageC: String? = null, | |||
| val remarks: String? = null, | |||
| val effectiveDays: Int? = null, | |||
| ) | |||
| @@ -159,6 +159,7 @@ fun findCompletedWithPlasticBoxCartonQtyInPlanStartRange( | |||
| ): List<PickOrder> | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.2 | 2026-08-25 */ | |||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | |||
| @Query( | |||
| value = """ | |||
| @@ -169,6 +170,7 @@ fun findCompletedWithPlasticBoxCartonQtyInPlanStartRange( | |||
| modifiedBy = :modifiedBy | |||
| WHERE deliveryOrderPickOrderId = :dopoId | |||
| AND deleted = 0 | |||
| AND LOWER(COALESCE(status, '')) <> 'completed' | |||
| """, | |||
| nativeQuery = true | |||
| ) | |||
| @@ -6,13 +6,16 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants | |||
| * Temporary consumable workbench exclude list until per-user DB config (Scheme A) lands. | |||
| * | |||
| * - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants]). | |||
| * - All other users → `null` → DO 2F default excludes in [SuggestedPickLotWorkbenchService]. | |||
| * - All other users → empty list (no warehouse limit). Do not return null: | |||
| * [SuggestedPickLotWorkbenchService] treats null as DO default 2F excludes. | |||
| * Consumable re-suggest keeps storeId null (same as first prime / JO); it does not use DO floor store resolution. | |||
| */ | |||
| object ConsumableWorkbenchPickConstants { | |||
| const val HARDCODED_EXCLUDE_USER_ID: Long = 246L | |||
| fun resolveExcludeWarehouseCodes(userId: Long): List<String>? { | |||
| if (userId != HARDCODED_EXCLUDE_USER_ID) return null | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.0 | 2026-08-13 */ | |||
| fun resolveExcludeWarehouseCodes(userId: Long): List<String> { | |||
| if (userId != HARDCODED_EXCLUDE_USER_ID) return emptyList() | |||
| return JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES.toList() | |||
| } | |||
| } | |||
| @@ -29,12 +29,13 @@ interface ProductProcessLineRepository : JpaRepository<ProductProcessLine, Long> | |||
| """) | |||
| fun findByProductProcess_IdInWithOperatorAndEquipment(@Param("ids") ids: List<Long>): List<ProductProcessLine> | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */ | |||
| @Query( | |||
| """ | |||
| SELECT | |||
| p.jobOrder.id AS jobOrderId, | |||
| COUNT(l.id) AS totalLines, | |||
| SUM(CASE WHEN l.status IN ('Completed', 'Pass') THEN 1 ELSE 0 END) AS doneLines | |||
| SUM(CASE WHEN l.status IN ('Completed', 'Pass', 'autoPass') THEN 1 ELSE 0 END) AS doneLines | |||
| FROM ProductProcessLine l | |||
| JOIN l.productProcess p | |||
| WHERE l.deleted = false | |||
| @@ -0,0 +1,400 @@ | |||
| package com.ffii.fpsms.modules.productProcess.service | |||
| import com.ffii.core.support.JdbcDao | |||
| import com.ffii.fpsms.modules.productProcess.web.model.DrinkShipmentQtyDeliveryDetail | |||
| import com.ffii.fpsms.modules.productProcess.web.model.DrinkShipmentQtyResponse | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.transaction.annotation.Transactional | |||
| import java.math.BigDecimal | |||
| import java.sql.Date | |||
| import java.sql.Timestamp | |||
| import java.time.LocalDate | |||
| @Service | |||
| @Transactional(readOnly = true) | |||
| open class DrinkShipmentQtyService( | |||
| private val jdbcDao: JdbcDao, | |||
| ) { | |||
| /** 訂單列:預計送貨日=選取日,或當日有掃出。實際出貨=當日 stock_ledger.outQty。 */ | |||
| open fun getDrinkShipmentQty(date: LocalDate?): List<DrinkShipmentQtyResponse> { | |||
| val targetDate = date ?: LocalDate.now() | |||
| val params = mapOf("targetDate" to targetDate.toString()) | |||
| val rows = jdbcDao.queryForList(dolSql, params) + | |||
| jdbcDao.queryForList(replenishmentOnlySql, params) | |||
| if (rows.isEmpty()) { | |||
| return emptyList() | |||
| } | |||
| data class GroupKey( | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| ) | |||
| data class DeliveryKey( | |||
| val deliveryOrderId: Long, | |||
| val itemCode: String?, | |||
| ) | |||
| val deliveriesByItem = linkedMapOf<GroupKey, MutableMap<DeliveryKey, Pair<DrinkShipmentQtyDeliveryDetail, String?>>>() | |||
| rows.forEach { row -> | |||
| val itemCode = jdbcString(row, "itemCode") | |||
| val itemName = jdbcString(row, "itemName") | |||
| val uom = jdbcString(row, "uom") | |||
| val itemKey = GroupKey(itemCode, itemName) | |||
| val detail = DrinkShipmentQtyDeliveryDetail( | |||
| deliveryOrderId = jdbcLong(row, "deliveryOrderId"), | |||
| deliveryOrderCode = jdbcString(row, "deliveryOrderCode"), | |||
| deliveryDate = jdbcDate(row, "deliveryDate"), | |||
| shopCode = jdbcString(row, "shopCode"), | |||
| shopName = shopLabel( | |||
| jdbcString(row, "shopCode"), | |||
| jdbcString(row, "shopName"), | |||
| ), | |||
| deliveryOrderStatus = jdbcString(row, "deliveryOrderStatus"), | |||
| orderQty = jdbcDecimal(row, "orderQty"), | |||
| shippedQty = jdbcDecimal(row, "shippedQty"), | |||
| ) | |||
| val deliveryKey = DeliveryKey(detail.deliveryOrderId, itemCode) | |||
| val byDelivery = deliveriesByItem.getOrPut(itemKey) { linkedMapOf() } | |||
| val existing = byDelivery[deliveryKey] | |||
| if (existing == null) { | |||
| byDelivery[deliveryKey] = detail to uom | |||
| } else { | |||
| val merged = existing.first.copy( | |||
| orderQty = existing.first.orderQty + detail.orderQty, | |||
| shippedQty = existing.first.shippedQty + detail.shippedQty, | |||
| shopName = existing.first.shopName ?: detail.shopName, | |||
| deliveryOrderCode = existing.first.deliveryOrderCode ?: detail.deliveryOrderCode, | |||
| deliveryOrderStatus = existing.first.deliveryOrderStatus ?: detail.deliveryOrderStatus, | |||
| ) | |||
| byDelivery[deliveryKey] = merged to (existing.second ?: uom) | |||
| } | |||
| } | |||
| return deliveriesByItem | |||
| .map { (key, byDelivery) -> | |||
| val deliveries = byDelivery.values | |||
| .map { it.first } | |||
| .sortedBy { it.deliveryOrderCode ?: "" } | |||
| DrinkShipmentQtyResponse( | |||
| itemCode = key.itemCode, | |||
| itemName = key.itemName, | |||
| uom = byDelivery.values.firstNotNullOfOrNull { it.second }, | |||
| totalOrderQty = deliveries.fold(BigDecimal.ZERO) { acc, row -> acc + row.orderQty }, | |||
| totalShippedQty = deliveries.fold(BigDecimal.ZERO) { acc, row -> acc + row.shippedQty }, | |||
| deliveries = deliveries, | |||
| ) | |||
| } | |||
| .sortedWith( | |||
| compareBy<DrinkShipmentQtyResponse> { it.itemCode ?: "" } | |||
| .thenBy { it.itemName ?: "" }, | |||
| ) | |||
| } | |||
| private fun shopLabel(shopCode: String?, shopName: String?): String? { | |||
| val code = shopCode?.trim()?.takeIf { it.isNotEmpty() } | |||
| val name = shopName?.trim()?.takeIf { it.isNotEmpty() } | |||
| return when { | |||
| code != null && name != null && name.startsWith("$code -") -> name | |||
| code != null && name != null -> "$code - $name" | |||
| name != null -> name | |||
| else -> code | |||
| } | |||
| } | |||
| private fun jdbcString(row: Map<String, Any?>, key: String): String? { | |||
| val value = row[key] ?: row[key.lowercase()] ?: return null | |||
| val text = value.toString().trim() | |||
| return text.takeIf { it.isNotEmpty() && !it.equals("null", ignoreCase = true) } | |||
| } | |||
| private fun jdbcLong(row: Map<String, Any?>, key: String): Long { | |||
| val value = row[key] ?: row[key.lowercase()] ?: return 0L | |||
| return when (value) { | |||
| is Number -> value.toLong() | |||
| else -> value.toString().toLongOrNull() ?: 0L | |||
| } | |||
| } | |||
| private fun jdbcDecimal(row: Map<String, Any?>, key: String): BigDecimal { | |||
| val value = row[key] ?: row[key.lowercase()] ?: return BigDecimal.ZERO | |||
| return when (value) { | |||
| is BigDecimal -> value | |||
| is Number -> BigDecimal(value.toString()) | |||
| else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO | |||
| } | |||
| } | |||
| private fun jdbcDate(row: Map<String, Any?>, key: String): LocalDate? { | |||
| val value = row[key] ?: row[key.lowercase()] ?: return null | |||
| return when (value) { | |||
| is LocalDate -> value | |||
| is Date -> value.toLocalDate() | |||
| is Timestamp -> value.toLocalDateTime().toLocalDate() | |||
| else -> runCatching { LocalDate.parse(value.toString().take(10)) }.getOrNull() | |||
| } | |||
| } | |||
| companion object { | |||
| private const val DRINK_ITEM_JOIN = """ | |||
| INNER JOIN ( | |||
| SELECT itemId, MIN(id) AS bomId | |||
| FROM bom | |||
| WHERE deleted = 0 | |||
| AND IFNULL(isDrink, 0) = 1 | |||
| AND itemId IS NOT NULL | |||
| GROUP BY itemId | |||
| ) drink_item ON drink_item.itemId = it.id | |||
| LEFT JOIN bom b ON b.id = drink_item.bomId | |||
| LEFT JOIN item_uom iu | |||
| ON iu.id = ( | |||
| SELECT MIN(iu2.id) | |||
| FROM item_uom iu2 | |||
| WHERE iu2.itemId = it.id | |||
| AND iu2.stockUnit = 1 | |||
| AND iu2.deleted = 0 | |||
| ) | |||
| LEFT JOIN uom_conversion uc ON uc.id = iu.uomId | |||
| """ | |||
| private const val SOL_DO_TYPE_FILTER = """ | |||
| AND LOWER(IFNULL(sol.type, 'nor')) NOT IN ('trf', 'adj', 'tke', 'stocktake', 'jo') | |||
| AND ( | |||
| so.id IS NULL | |||
| OR ( | |||
| IFNULL(so.deleted, 0) = 0 | |||
| AND LOWER(IFNULL(so.type, 'do')) = 'do' | |||
| ) | |||
| ) | |||
| """ | |||
| /** | |||
| * 當天掃出去的量:優先 stock_ledger.outQty(跨日提料只計當日增量)。 | |||
| * 無 ledger 的舊資料才 fallback 到當日 pickTime 的 sol.qty。 | |||
| */ | |||
| private const val SHIPPED_QTY_FOR_DO = """ | |||
| IFNULL(( | |||
| SELECT SUM( | |||
| CASE | |||
| WHEN sl.id IS NOT NULL THEN IFNULL(sl.outQty, 0) | |||
| WHEN DATE(IFNULL(sol.pickTime, sol.created)) = :targetDate | |||
| AND NOT EXISTS ( | |||
| SELECT 1 | |||
| FROM stock_ledger slx | |||
| WHERE slx.stockOutLineId = sol.id | |||
| AND IFNULL(slx.deleted, 0) = 0 | |||
| ) | |||
| THEN IFNULL(sol.qty, 0) | |||
| ELSE 0 | |||
| END | |||
| ) | |||
| FROM pick_order po | |||
| INNER JOIN pick_order_line pol | |||
| ON pol.poId = po.id | |||
| AND pol.deleted = 0 | |||
| AND pol.itemId = it.id | |||
| INNER JOIN stock_out_line sol | |||
| ON sol.pickOrderLineId = pol.id | |||
| AND sol.deleted = 0 | |||
| LEFT JOIN stock_out so | |||
| ON so.id = sol.stockOutId | |||
| LEFT JOIN stock_ledger sl | |||
| ON sl.stockOutLineId = sol.id | |||
| AND IFNULL(sl.deleted, 0) = 0 | |||
| AND DATE(sl.date) = :targetDate | |||
| AND IFNULL(sl.outQty, 0) > 0 | |||
| WHERE po.deleted = 0 | |||
| AND po.doId = do.id | |||
| AND LOWER(IFNULL(po.type, 'do')) = 'do' | |||
| $SOL_DO_TYPE_FILTER | |||
| ), 0) | |||
| """ | |||
| private const val SCANNED_TODAY_FOR_DO = """ | |||
| EXISTS ( | |||
| SELECT 1 | |||
| FROM pick_order po | |||
| INNER JOIN pick_order_line pol | |||
| ON pol.poId = po.id | |||
| AND pol.deleted = 0 | |||
| AND pol.itemId = it.id | |||
| INNER JOIN stock_out_line sol | |||
| ON sol.pickOrderLineId = pol.id | |||
| AND sol.deleted = 0 | |||
| LEFT JOIN stock_out so | |||
| ON so.id = sol.stockOutId | |||
| LEFT JOIN stock_ledger sl | |||
| ON sl.stockOutLineId = sol.id | |||
| AND IFNULL(sl.deleted, 0) = 0 | |||
| AND DATE(sl.date) = :targetDate | |||
| AND IFNULL(sl.outQty, 0) > 0 | |||
| WHERE po.deleted = 0 | |||
| AND po.doId = do.id | |||
| AND LOWER(IFNULL(po.type, 'do')) = 'do' | |||
| $SOL_DO_TYPE_FILTER | |||
| AND ( | |||
| sl.id IS NOT NULL | |||
| OR ( | |||
| IFNULL(sol.qty, 0) > 0 | |||
| AND DATE(IFNULL(sol.pickTime, sol.created)) = :targetDate | |||
| AND NOT EXISTS ( | |||
| SELECT 1 | |||
| FROM stock_ledger slx | |||
| WHERE slx.stockOutLineId = sol.id | |||
| AND IFNULL(slx.deleted, 0) = 0 | |||
| ) | |||
| ) | |||
| ) | |||
| ) | |||
| """ | |||
| private val dolSql = """ | |||
| SELECT | |||
| it.code AS itemCode, | |||
| it.name AS itemName, | |||
| COALESCE( | |||
| NULLIF(TRIM(b.outputQtyUom), ''), | |||
| NULLIF(TRIM(b.excelUom), ''), | |||
| uc.udfudesc | |||
| ) AS uom, | |||
| do.id AS deliveryOrderId, | |||
| do.code AS deliveryOrderCode, | |||
| do.status AS deliveryOrderStatus, | |||
| DATE(IFNULL(do.estimatedArrivalDate, do.orderDate)) AS deliveryDate, | |||
| IFNULL(sp.code, '') AS shopCode, | |||
| IFNULL(sp.name, '') AS shopName, | |||
| SUM(IFNULL(dol.qty, 0)) | |||
| + IFNULL(( | |||
| SELECT SUM(IFNULL(r.replenishQty, 0)) | |||
| FROM do_replenishment r | |||
| WHERE IFNULL(r.deleted, 0) = 0 | |||
| AND r.targetDoId = do.id | |||
| AND r.itemId = it.id | |||
| ), 0) AS orderQty, | |||
| $SHIPPED_QTY_FOR_DO AS shippedQty | |||
| FROM delivery_order_line dol | |||
| INNER JOIN delivery_order do | |||
| ON dol.deliveryOrderId = do.id | |||
| AND do.deleted = 0 | |||
| INNER JOIN items it | |||
| ON dol.itemId = it.id | |||
| AND it.deleted = 0 | |||
| $DRINK_ITEM_JOIN | |||
| LEFT JOIN shop sp | |||
| ON do.shopId = sp.id | |||
| AND sp.deleted = 0 | |||
| WHERE dol.deleted = 0 | |||
| AND ( | |||
| DATE(IFNULL(do.estimatedArrivalDate, do.orderDate)) = :targetDate | |||
| OR $SCANNED_TODAY_FOR_DO | |||
| ) | |||
| GROUP BY | |||
| it.id, | |||
| it.code, | |||
| it.name, | |||
| b.outputQtyUom, | |||
| b.excelUom, | |||
| uc.udfudesc, | |||
| do.id, | |||
| do.code, | |||
| do.status, | |||
| DATE(IFNULL(do.estimatedArrivalDate, do.orderDate)), | |||
| sp.code, | |||
| sp.name | |||
| ORDER BY it.code, do.code | |||
| """.trimIndent() | |||
| private val replenishmentOnlySql = """ | |||
| SELECT | |||
| it.code AS itemCode, | |||
| it.name AS itemName, | |||
| COALESCE( | |||
| NULLIF(TRIM(b.outputQtyUom), ''), | |||
| NULLIF(TRIM(b.excelUom), ''), | |||
| uc.udfudesc | |||
| ) AS uom, | |||
| COALESCE(r.targetDoId, r.sourceDoId, 0) AS deliveryOrderId, | |||
| COALESCE(do.code, r.targetDoCode, r.sourceDoCode) AS deliveryOrderCode, | |||
| do.status AS deliveryOrderStatus, | |||
| r.deliveryDate AS deliveryDate, | |||
| IFNULL(r.shopCode, IFNULL(sp.code, '')) AS shopCode, | |||
| IFNULL(r.shopName, IFNULL(sp.name, '')) AS shopName, | |||
| SUM(IFNULL(r.replenishQty, 0)) AS orderQty, | |||
| IFNULL(MAX(ship.shippedQty), 0) AS shippedQty | |||
| FROM do_replenishment r | |||
| INNER JOIN items it | |||
| ON r.itemId = it.id | |||
| AND it.deleted = 0 | |||
| $DRINK_ITEM_JOIN | |||
| LEFT JOIN delivery_order do | |||
| ON do.id = COALESCE(r.targetDoId, r.sourceDoId) | |||
| AND do.deleted = 0 | |||
| LEFT JOIN shop sp | |||
| ON do.shopId = sp.id | |||
| AND sp.deleted = 0 | |||
| LEFT JOIN ( | |||
| SELECT | |||
| COALESCE(r2.targetDoId, r2.sourceDoId, 0) AS deliveryOrderId, | |||
| r2.itemId AS itemId, | |||
| SUM( | |||
| CASE | |||
| WHEN sl.id IS NOT NULL THEN IFNULL(sl.outQty, 0) | |||
| WHEN DATE(IFNULL(sol.pickTime, sol.created)) = :targetDate | |||
| AND NOT EXISTS ( | |||
| SELECT 1 | |||
| FROM stock_ledger slx | |||
| WHERE slx.stockOutLineId = sol.id | |||
| AND IFNULL(slx.deleted, 0) = 0 | |||
| ) | |||
| THEN IFNULL(sol.qty, 0) | |||
| ELSE 0 | |||
| END | |||
| ) AS shippedQty | |||
| FROM do_replenishment r2 | |||
| INNER JOIN stock_out_line sol | |||
| ON sol.pickOrderLineId = r2.pickOrderLineId | |||
| AND sol.deleted = 0 | |||
| LEFT JOIN stock_out so | |||
| ON so.id = sol.stockOutId | |||
| LEFT JOIN stock_ledger sl | |||
| ON sl.stockOutLineId = sol.id | |||
| AND IFNULL(sl.deleted, 0) = 0 | |||
| AND DATE(sl.date) = :targetDate | |||
| AND IFNULL(sl.outQty, 0) > 0 | |||
| WHERE IFNULL(r2.deleted, 0) = 0 | |||
| $SOL_DO_TYPE_FILTER | |||
| GROUP BY | |||
| COALESCE(r2.targetDoId, r2.sourceDoId, 0), | |||
| r2.itemId | |||
| ) ship | |||
| ON ship.deliveryOrderId = COALESCE(r.targetDoId, r.sourceDoId, 0) | |||
| AND ship.itemId = it.id | |||
| WHERE IFNULL(r.deleted, 0) = 0 | |||
| AND ( | |||
| r.deliveryDate = :targetDate | |||
| OR IFNULL(ship.shippedQty, 0) > 0 | |||
| ) | |||
| AND NOT EXISTS ( | |||
| SELECT 1 | |||
| FROM delivery_order_line dol | |||
| WHERE dol.deleted = 0 | |||
| AND dol.itemId = r.itemId | |||
| AND dol.deliveryOrderId = COALESCE(r.targetDoId, r.sourceDoId) | |||
| ) | |||
| GROUP BY | |||
| it.id, | |||
| it.code, | |||
| it.name, | |||
| b.outputQtyUom, | |||
| b.excelUom, | |||
| uc.udfudesc, | |||
| COALESCE(r.targetDoId, r.sourceDoId, 0), | |||
| COALESCE(do.code, r.targetDoCode, r.sourceDoCode), | |||
| do.status, | |||
| r.deliveryDate, | |||
| IFNULL(r.shopCode, IFNULL(sp.code, '')), | |||
| IFNULL(r.shopName, IFNULL(sp.name, '')) | |||
| ORDER BY it.code, COALESCE(do.code, r.targetDoCode, r.sourceDoCode) | |||
| """.trimIndent() | |||
| } | |||
| } | |||
| @@ -1307,7 +1307,7 @@ open class ProductProcessService( | |||
| ) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */ | |||
| open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse { | |||
| println(" Service: Updating ProductProcessLine Status: $productProcessLineId") | |||
| val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) | |||
| @@ -1317,8 +1317,8 @@ open class ProductProcessService( | |||
| productProcessLineRepository.save(productProcessLine) | |||
| println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}") | |||
| // One packaging Complete/Pass → auto-Pass remaining packaging lines on the same JO. | |||
| if (isLineDone(status) && isPackagingLine(productProcessLine)) { | |||
| // One packaging Complete/Pass → autoPass remaining packaging lines on the same JO. | |||
| if (isManualLineDone(status) && isPackagingLine(productProcessLine)) { | |||
| autoPassSiblingPackagingLines(productProcessLineId) | |||
| } | |||
| @@ -1376,7 +1376,7 @@ open class ProductProcessService( | |||
| val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | |||
| println(" Service: ProductProcessLines: $productProcessLines") | |||
| if (productProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { | |||
| if (productProcessLines.all { isLineDone(it.status) }) { | |||
| productProcess.status = ProductProcessStatus.COMPLETED | |||
| if (productProcess.endTime == null) { | |||
| productProcess.endTime = LocalDateTime.now() | |||
| @@ -1568,9 +1568,9 @@ bomDescription = productProcesses.bom?.bomKind, | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.2 | 2026-08-09 | |||
| * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 | |||
| * (包裝 Complete/Pass 時會自動 Pass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。) | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 | |||
| * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass/autoPass 决定。 | |||
| * (包裝 Complete/Pass 時會自動 autoPass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。) | |||
| * | |||
| * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 | |||
| */ | |||
| @@ -1720,10 +1720,6 @@ bomDescription = productProcesses.bom?.bomKind, | |||
| val linesByJobOrder = candidateLines.groupBy { it.productProcess.jobOrder?.id ?: 0L } | |||
| val done = { s: String? -> | |||
| val x = s?.trim()?.lowercase() | |||
| x == "completed" || x == "pass" | |||
| } | |||
| val qcKeys = candidateAggregates.mapNotNull { agg -> | |||
| val jobOrderId = agg.jobOrderId | |||
| val stockInLine = stockInLineByJobOrderId[jobOrderId] | |||
| @@ -1745,15 +1741,10 @@ bomDescription = productProcesses.bom?.bomKind, | |||
| val lineAggregate = lineStatusByJobOrderId[jobOrderId] | |||
| val totalLines = lineAggregate?.totalLines ?: 0L | |||
| val done = { s: String? -> | |||
| val x = s?.trim()?.lowercase() | |||
| x == "completed" || x == "pass" | |||
| } | |||
| // After packaging Complete/Pass auto-passes sibling 包裝 lines, qcReady is simply all lines done. | |||
| // After packaging Complete/Pass autoPasses sibling 包裝 lines, qcReady is simply all lines done. | |||
| val jobLines = linesByJobOrder[jobOrderId].orEmpty() | |||
| val allLinesDone = jobLines.isNotEmpty() && jobLines.all { done(it.status) } | |||
| val allLinesDone = jobLines.isNotEmpty() && jobLines.all { isLineDone(it.status) } | |||
| val ready = includedInList && stockInLine != null && allLinesDone | |||
| @@ -1943,10 +1934,7 @@ bomDescription = productProcesses.bom?.bomKind, | |||
| val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() | |||
| val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty() | |||
| val finishedCount = productProcessLines.count { | |||
| val s = it.status?.trim()?.lowercase() | |||
| s == "completed" || s == "pass" | |||
| } | |||
| val finishedCount = productProcessLines.count { isLineDone(it.status) } | |||
| val bomIsDrink = productProcess.bom?.isDrink | |||
| val matchStatus = if (joPickOrdersList.isNotEmpty() && | |||
| @@ -2020,7 +2008,7 @@ bomDescription = productProcess.bom?.bomKind, | |||
| } | |||
| /** | |||
| * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass, stock-in exists and not completed/rejected; | |||
| * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass/autoPass, stock-in exists and not completed/rejected; | |||
| * only job orders that have a product process dated **today or yesterday** (server local date). | |||
| */ | |||
| open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse { | |||
| @@ -2074,7 +2062,7 @@ bomDescription = productProcess.bom?.bomKind, | |||
| val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } | |||
| val allLinesDone = jobOrderLines.isNotEmpty() && | |||
| jobOrderLines.all { it.status == "Completed" || it.status == "Pass" } | |||
| jobOrderLines.all { isLineDone(it.status) } | |||
| if (!allLinesDone) continue | |||
| val maxDate = processes.mapNotNull { it.date }.maxOrNull() | |||
| @@ -2176,6 +2164,12 @@ bomDescription = productProcess.bom?.bomKind, | |||
| status?.trim()?.lowercase()?.replace(" ", "") ?: "" | |||
| private fun isLineDone(status: String?): Boolean { | |||
| val n = normalizeLineStatus(status) | |||
| return n == "completed" || n == "pass" || n == "autopass" | |||
| } | |||
| /** Manual Complete/Pass only — does not include autoPass (avoids cascade loops). */ | |||
| private fun isManualLineDone(status: String?): Boolean { | |||
| val n = normalizeLineStatus(status) | |||
| return n == "completed" || n == "pass" | |||
| } | |||
| @@ -2185,14 +2179,14 @@ bomDescription = productProcess.bom?.bomKind, | |||
| return n == "inprogress" || n == "paused" | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */ | |||
| private fun isPackagingLine(line: ProductProcessLine): Boolean = | |||
| (line.name ?: "").trim() == PACKAGING_PROCESS_NAME | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 | |||
| * When one 包裝 line is Completed/Pass, auto-Pass other unfinished 包裝 lines on the same job order, | |||
| * then sync each affected ProductProcess.status (Pass counts as done → COMPLETED). | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 | |||
| * When one 包裝 line is Completed/Pass, set other unfinished 包裝 lines on the same job order to autoPass, | |||
| * then sync each affected ProductProcess.status (autoPass counts as done → COMPLETED). | |||
| */ | |||
| private fun autoPassSiblingPackagingLines(triggerLineId: Long) { | |||
| val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return | |||
| @@ -2220,7 +2214,7 @@ bomDescription = productProcess.bom?.bomKind, | |||
| line.startTime = now | |||
| } | |||
| line.endTime = now | |||
| line.status = "Pass" | |||
| line.status = "autoPass" | |||
| productProcessLineRepository.save(line) | |||
| line.productProcess?.id?.let { affectedProcessIds.add(it) } | |||
| } | |||
| @@ -2232,7 +2226,7 @@ bomDescription = productProcess.bom?.bomKind, | |||
| /** | |||
| * Align parent [ProductProcess.status] with all line states. | |||
| * - All Completed/Pass -> completed (via [ifAllLinesCompletedOrPassed]) | |||
| * - All Completed/Pass/autoPass -> completed (via [ifAllLinesCompletedOrPassed]) | |||
| * - Any line started but not all done -> in_progress | |||
| * - Does not override STOPPED or CANCELLED on the parent. | |||
| */ | |||
| @@ -2312,8 +2306,8 @@ bomDescription = productProcess.bom?.bomKind, | |||
| // 获取所有 product process lines | |||
| val allproductProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | |||
| // 检查是否所有 lines 都是 "Completed" 或 "Pass" | |||
| if (allproductProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { | |||
| // 检查是否所有 lines 都是 Completed / Pass / autoPass | |||
| if (allproductProcessLines.all { isLineDone(it.status) }) { | |||
| // 更新 product process 的 endTime 和状态 | |||
| updateProductProcessEndTime(productProcessId) | |||
| updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) | |||
| @@ -2828,6 +2822,7 @@ bomDescription = productProcess.bom?.bomKind, | |||
| } | |||
| val drinkOrders = candidateJobOrders | |||
| .filter { it.isHidden != true } | |||
| .filter { it.bom?.isDrink == true } | |||
| .filter { jo -> | |||
| isPlannedView || jo.status != JobOrderStatus.PLANNING | |||
| @@ -3154,7 +3149,7 @@ bomDescription = productProcess.bom?.bomKind, | |||
| } | |||
| val currentProcesses = operatorLines | |||
| .filter { it.endTime == null || (it.status != null && it.status != "Completed" && it.status != "Pass") } | |||
| .filter { it.endTime == null || (it.status != null && !isLineDone(it.status)) } | |||
| .map { line -> | |||
| val productProcess = line.productProcess | |||
| val jobOrder = productProcess.jobOrder | |||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.productProcess.web | |||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcess | |||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessLine | |||
| import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus | |||
| import com.ffii.fpsms.modules.productProcess.service.DrinkShipmentQtyService | |||
| import com.ffii.fpsms.modules.productProcess.service.ProductProcessService | |||
| import com.ffii.fpsms.modules.productProcess.web.model.* | |||
| import org.springframework.data.domain.Page | |||
| @@ -16,7 +17,8 @@ import java.time.format.DateTimeFormatter | |||
| @RestController | |||
| @RequestMapping("/product-process") | |||
| class ProductProcessController( | |||
| private val productProcessService: ProductProcessService | |||
| private val productProcessService: ProductProcessService, | |||
| private val drinkShipmentQtyService: DrinkShipmentQtyService, | |||
| ) { | |||
| @GetMapping | |||
| @@ -200,7 +202,7 @@ class ProductProcessController( | |||
| return productProcessService.getAllJoborderProductProcessInfo(bomType) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */ | |||
| @GetMapping("/Demo/Process/search") | |||
| fun demoprocesssearch( | |||
| @RequestParam(required = false) date: String?, | |||
| @@ -333,4 +335,14 @@ class ProductProcessController( | |||
| } | |||
| return productProcessService.getDrinkProductionQty(parsedDate, viewMode) | |||
| } | |||
| @GetMapping("/Demo/DrinkShipmentQty") | |||
| fun getDrinkShipmentQty( | |||
| @RequestParam(required = false) date: String?, | |||
| ): List<DrinkShipmentQtyResponse> { | |||
| val parsedDate = date?.takeIf { it.isNotBlank() }?.let { | |||
| LocalDate.parse(it, DateTimeFormatter.ISO_DATE) | |||
| } | |||
| return drinkShipmentQtyService.getDrinkShipmentQty(parsedDate) | |||
| } | |||
| } | |||
| @@ -218,7 +218,7 @@ data class JobOrderProductProcessPageResponse( | |||
| ) | |||
| /** | |||
| * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass, stock-in not completed/rejected; | |||
| * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass/autoPass, stock-in not completed/rejected; | |||
| * job order has at least one process dated today or yesterday. [qc] = before received; [putAway] = received / partially_completed. | |||
| */ | |||
| data class JobOrderFgAlertRowResponse( | |||
| @@ -400,4 +400,25 @@ data class DrinkProductionQtyResponse( | |||
| val totalReqQty: BigDecimal, | |||
| val totalQty: BigDecimal, | |||
| val jobOrders: List<DrinkProductionQtyJobOrderDetail> = emptyList(), | |||
| ) | |||
| data class DrinkShipmentQtyDeliveryDetail( | |||
| val deliveryOrderId: Long, | |||
| val deliveryOrderCode: String?, | |||
| @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") | |||
| val deliveryDate: LocalDate?, | |||
| val shopCode: String?, | |||
| val shopName: String?, | |||
| val deliveryOrderStatus: String?, | |||
| val orderQty: BigDecimal, | |||
| val shippedQty: BigDecimal, | |||
| ) | |||
| data class DrinkShipmentQtyResponse( | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| val uom: String?, | |||
| val totalOrderQty: BigDecimal, | |||
| val totalShippedQty: BigDecimal, | |||
| val deliveries: List<DrinkShipmentQtyDeliveryDetail> = emptyList(), | |||
| ) | |||
| @@ -0,0 +1,128 @@ | |||
| package com.ffii.fpsms.modules.report.service | |||
| import com.ffii.core.support.JdbcDao | |||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService | |||
| import org.springframework.stereotype.Service | |||
| import java.time.LocalDate | |||
| import java.time.format.DateTimeParseException | |||
| @Service | |||
| class DoInventoryUomMismatchReportService( | |||
| private val jdbcDao: JdbcDao, | |||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||
| ) { | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10 | |||
| * Pending DO lines by estimatedArrivalDate whose current inventory lot UOM | |||
| * does not match the DO line UOM. Excel-only report. | |||
| * Supplier scope follows DoSearch floor lists (2F / 4F / All = both). | |||
| */ | |||
| fun searchDoInventoryUomMismatch( | |||
| deliveryDate: String?, | |||
| storeId: String? = null, | |||
| ): List<Map<String, Any?>> { | |||
| val raw = deliveryDate?.trim()?.replace("/", "-").orEmpty() | |||
| if (raw.isBlank()) { | |||
| throw IllegalArgumentException("deliveryDate is required") | |||
| } | |||
| val date = try { | |||
| LocalDate.parse(raw) | |||
| } catch (_: DateTimeParseException) { | |||
| throw IllegalArgumentException("deliveryDate must be yyyy-MM-dd") | |||
| } | |||
| val today = LocalDate.now() | |||
| if (date.isBefore(today)) { | |||
| throw IllegalArgumentException("deliveryDate must not be before today") | |||
| } | |||
| val floor = storeId?.trim().orEmpty().let { f -> | |||
| when { | |||
| f.isEmpty() || f.equals("All", ignoreCase = true) -> "ALL" | |||
| else -> f | |||
| } | |||
| } | |||
| val allowedSupplierCodes = doFloorSupplierSettingsService.allowedSupplierCodesForFloor(floor) | |||
| if (allowedSupplierCodes.isEmpty()) { | |||
| return emptyList() | |||
| } | |||
| val args = mutableMapOf<String, Any>( | |||
| "deliveryDate" to date.toString(), | |||
| "allowedSupplierCodes" to allowedSupplierCodes, | |||
| ) | |||
| // Drive from pending DO by estimatedArrivalDate (not orderDate / pick requiredDeliveryDate). | |||
| // STRAIGHT_JOIN keeps DO CTE as driver (avoids full inventory_lot scan). | |||
| // Supplier filter matches DoSearch: d.supplier.code IN floor settings (2F/4F/All). | |||
| val sql = """ | |||
| WITH do_lines AS ( | |||
| SELECT DISTINCT | |||
| do.code AS doCode, | |||
| DATE(do.estimatedArrivalDate) AS deliveryDate, | |||
| s.code AS supplierCode, | |||
| s.name AS supplierName, | |||
| dol.id AS doLineId, | |||
| dol.itemId AS itemId, | |||
| dol.itemNo AS itemNo, | |||
| dol.qty AS doQty, | |||
| dol.uomId AS doUomId, | |||
| uc_do.udfudesc AS doUom | |||
| FROM delivery_order do | |||
| INNER JOIN shop s | |||
| ON s.id = do.supplierId | |||
| AND s.deleted = 0 | |||
| AND s.code IN (:allowedSupplierCodes) | |||
| INNER JOIN delivery_order_line dol | |||
| ON dol.deliveryOrderId = do.id | |||
| AND dol.deleted = 0 | |||
| LEFT JOIN uom_conversion uc_do | |||
| ON uc_do.id = dol.uomId | |||
| AND uc_do.deleted = 0 | |||
| WHERE do.deleted = 0 | |||
| AND do.status = 'pending' | |||
| AND do.supplierId IS NOT NULL | |||
| AND do.estimatedArrivalDate IS NOT NULL | |||
| AND DATE(do.estimatedArrivalDate) = :deliveryDate | |||
| ) | |||
| SELECT | |||
| DATE_FORMAT(d.deliveryDate, '%Y-%m-%d') AS deliveryDate, | |||
| d.doCode AS doCode, | |||
| d.supplierCode AS supplierCode, | |||
| d.supplierName AS supplierName, | |||
| d.itemNo AS itemCode, | |||
| it.name AS itemName, | |||
| d.doQty AS doQty, | |||
| d.doUom AS doUom, | |||
| d.doUomId AS doUomId, | |||
| il.lotNo AS mismatchLotNo, | |||
| (IFNULL(ill.inQty, 0) - IFNULL(ill.outQty, 0)) AS mismatchLotQty, | |||
| uc_ill.udfudesc AS mismatchLotUom, | |||
| iu.uomId AS inventoryUomId | |||
| FROM do_lines d | |||
| STRAIGHT_JOIN inventory_lot il | |||
| ON il.itemId = d.itemId | |||
| AND il.deleted = 0 | |||
| STRAIGHT_JOIN inventory_lot_line ill | |||
| ON ill.inventoryLotId = il.id | |||
| AND ill.deleted = 0 | |||
| AND (IFNULL(ill.inQty, 0) - IFNULL(ill.outQty, 0)) > 0 | |||
| STRAIGHT_JOIN item_uom iu | |||
| ON iu.id = ill.stockItemUomId | |||
| AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc_ill | |||
| ON uc_ill.id = iu.uomId | |||
| AND uc_ill.deleted = 0 | |||
| LEFT JOIN items it | |||
| ON it.id = d.itemId | |||
| AND it.deleted = 0 | |||
| WHERE d.doUomId IS NULL | |||
| OR iu.uomId IS NULL | |||
| OR d.doUomId <> iu.uomId | |||
| ORDER BY d.doCode, d.itemNo, il.lotNo | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| } | |||
| } | |||
| @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service | |||
| open class ItemQcFailReportService( | |||
| private val jdbcDao: JdbcDao, | |||
| ) { | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ | |||
| fun searchItemQcFailReport( | |||
| stockCategory: String?, | |||
| itemCode: String?, | |||
| @@ -33,16 +34,19 @@ open class ItemQcFailReportService( | |||
| val qcItemScopeSql = buildQcItemScopeClause(measurable, other) | |||
| val measuredValueSql = buildMeasuredValueClause(measurable, scope) | |||
| // Date filter = QC result created time (true QC finish), not receiptDate / put-away. | |||
| // JO EPQC: receiptDate is often SIL create day; put-away ≈ QC via auto put-away. | |||
| // PO IQC: put-away can be days after QC — must not use ill.created for this filter. | |||
| val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { | |||
| val formattedDate = lastInDateStart.replace("/", "-") | |||
| args["lastInDateStart"] = formattedDate | |||
| "AND DATE(sil.receiptDate) >= DATE(:lastInDateStart)" | |||
| "AND DATE(qr.created) >= DATE(:lastInDateStart)" | |||
| } else "" | |||
| val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { | |||
| val formattedDate = lastInDateEnd.replace("/", "-") | |||
| args["lastInDateEnd"] = formattedDate | |||
| "AND DATE(sil.receiptDate) <= DATE(:lastInDateEnd)" | |||
| "AND DATE(qr.created) <= DATE(:lastInDateEnd)" | |||
| } else "" | |||
| val sql = """ | |||
| @@ -15,6 +15,7 @@ import java.math.BigDecimal | |||
| class ShopOrderReplenishmentReportService( | |||
| private val jdbcDao: JdbcDao, | |||
| ) { | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 49 | v1.0.1 | 2026-08-10 */ | |||
| fun searchShopOrderReplenishmentReport( | |||
| reorderDateStart: String?, | |||
| reorderDateEnd: String?, | |||
| @@ -87,6 +88,7 @@ class ShopOrderReplenishmentReportService( | |||
| DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate, | |||
| dr.reason AS reason, | |||
| DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate, | |||
| TRIM(IFNULL(dop.handlerName, '')) AS actualDeliveredHandler, | |||
| dr.sourceDoId AS sourceDoId, | |||
| dr.itemId AS itemId, | |||
| dr.pickOrderLineId AS pickOrderLineId | |||
| @@ -130,7 +132,7 @@ class ShopOrderReplenishmentReportService( | |||
| val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct() | |||
| val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds) | |||
| val firstOrderPickBySourceKey = loadFirstOrderActualPickQty(sourceDoIds, itemIds, pickOrderLineIds) | |||
| val firstOrderPickBySourceKey = loadFirstOrderPickInfo(sourceDoIds, itemIds, pickOrderLineIds) | |||
| val rows = baseRows.map { row -> | |||
| val polId = longVal(row["pickOrderLineId"]) | |||
| @@ -138,6 +140,7 @@ class ShopOrderReplenishmentReportService( | |||
| val itemId = longVal(row["itemId"]) | |||
| val sourceKey = | |||
| if (sourceDoId != null && itemId != null) sourceDoId to itemId else null | |||
| val firstOrder = sourceKey?.let { firstOrderPickBySourceKey[it] } | |||
| linkedMapOf<String, Any?>( | |||
| "shopNo" to row["shopNo"], | |||
| @@ -147,11 +150,13 @@ class ShopOrderReplenishmentReportService( | |||
| "itemNo" to row["itemNo"], | |||
| "itemName" to row["itemName"], | |||
| "firstOrderQty" to row["firstOrderQty"], | |||
| "firstOrderActualPickQty" to (sourceKey?.let { firstOrderPickBySourceKey[it] } ?: BigDecimal.ZERO), | |||
| "firstOrderActualPickQty" to (firstOrder?.qty ?: BigDecimal.ZERO), | |||
| "firstOrderPickerHandler" to (firstOrder?.handler ?: ""), | |||
| "reorderQty" to row["reorderQty"], | |||
| "reorderDate" to row["reorderDate"], | |||
| "reason" to row["reason"], | |||
| "actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO), | |||
| "actualDeliveredHandler" to (row["actualDeliveredHandler"] ?: ""), | |||
| "deliveredDate" to row["deliveredDate"], | |||
| ) | |||
| } | |||
| @@ -185,14 +190,15 @@ class ShopOrderReplenishmentReportService( | |||
| } | |||
| /** | |||
| * Sum stock_out_line.qty for source DO pick order lines of the same item, | |||
| * First-order pick qty + handler for source DO + item, | |||
| * excluding any POL that is itself a replenishment line (incl. current report POLs). | |||
| * Same ticket has at most one handler; MAX() is for GROUP BY only. | |||
| */ | |||
| private fun loadFirstOrderActualPickQty( | |||
| private fun loadFirstOrderPickInfo( | |||
| sourceDoIds: List<Long>, | |||
| itemIds: List<Long>, | |||
| excludePickOrderLineIds: List<Long>, | |||
| ): Map<Pair<Long, Long>, BigDecimal> { | |||
| ): Map<Pair<Long, Long>, FirstOrderPickInfo> { | |||
| if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() | |||
| val args = mutableMapOf<String, Any>( | |||
| @@ -210,7 +216,8 @@ class ShopOrderReplenishmentReportService( | |||
| SELECT | |||
| po.doId AS sourceDoId, | |||
| pol.itemId AS itemId, | |||
| SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty | |||
| SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty, | |||
| MAX(TRIM(IFNULL(dop.handlerName, ''))) AS firstOrderPickerHandler | |||
| FROM pick_order po | |||
| INNER JOIN pick_order_line pol | |||
| ON pol.poId = po.id | |||
| @@ -218,6 +225,9 @@ class ShopOrderReplenishmentReportService( | |||
| LEFT JOIN stock_out_line sol | |||
| ON sol.pickOrderLineId = pol.id | |||
| AND IFNULL(sol.deleted, 0) = 0 | |||
| LEFT JOIN delivery_order_pick_order dop | |||
| ON dop.id = po.deliveryOrderPickOrderId | |||
| AND IFNULL(dop.deleted, 0) = 0 | |||
| WHERE IFNULL(po.deleted, 0) = 0 | |||
| AND po.doId IN (:sourceDoIds) | |||
| AND pol.itemId IN (:itemIds) | |||
| @@ -236,10 +246,18 @@ class ShopOrderReplenishmentReportService( | |||
| return rows.mapNotNull { row -> | |||
| val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null | |||
| val itemId = longVal(row["itemId"]) ?: return@mapNotNull null | |||
| (sourceDoId to itemId) to decimalVal(row["firstOrderActualPickQty"]) | |||
| (sourceDoId to itemId) to FirstOrderPickInfo( | |||
| qty = decimalVal(row["firstOrderActualPickQty"]), | |||
| handler = row["firstOrderPickerHandler"]?.toString()?.trim().orEmpty(), | |||
| ) | |||
| }.toMap() | |||
| } | |||
| private data class FirstOrderPickInfo( | |||
| val qty: BigDecimal, | |||
| val handler: String, | |||
| ) | |||
| private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-") | |||
| private fun dateStartClause( | |||
| @@ -4,17 +4,25 @@ import com.ffii.core.support.JdbcDao | |||
| import org.springframework.stereotype.Service | |||
| import java.time.LocalDate | |||
| import java.time.format.DateTimeFormatter | |||
| @Service | |||
| open class StockLedgerReportService( | |||
| private val jdbcDao: JdbcDao, | |||
| ) { | |||
| private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 | |||
| * Stock Ledger 報表查詢 | |||
| * | |||
| * - stockSubCategory = items.type | |||
| * - trnDate = stock_ledger.date | |||
| * - trnRefNo = stock_ledger.type | |||
| * - cumBalance = stock_ledger.balance(異動後纍計存量) | |||
| * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) | |||
| * | |||
| * 只查 [start, end] 期間列,不掃起日以前全歷史。 | |||
| */ | |||
| fun searchStockLedgerReport( | |||
| stockCategory: String?, | |||
| @@ -23,18 +31,15 @@ open class StockLedgerReportService( | |||
| reportPeriodStart: String?, | |||
| reportPeriodEnd: String?, | |||
| ): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| // 1) 先決定 reportPeriodEnd:如果有填 end,就用使用者的;否則用今天 | |||
| val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") | |||
| ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) | |||
| // 2) 如果有填 start,就用使用者的;否則從 DB 查最早一筆日期 | |||
| ?: LocalDate.now().format(dateFmt)) | |||
| val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { | |||
| reportPeriodStart.replace("/", "-") | |||
| } else { | |||
| // 用簡單 SQL 查全表最早一筆日期(可加上類似 stockCategory/itemCode 過濾) | |||
| val minDateSql = """ | |||
| SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate | |||
| FROM stock_ledger sl | |||
| @@ -42,188 +47,39 @@ open class StockLedgerReportService( | |||
| AND sl.itemCode IS NOT NULL | |||
| AND sl.itemCode <> '' | |||
| """.trimIndent() | |||
| val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull() | |||
| (minDateRow?.get("firstDate") as? String) | |||
| ?: reportPeriodEnd // 如果表是空的,就退回用今天 | |||
| ?: reportPeriodEnd | |||
| } | |||
| // 3) 把 from/to 塞到 args,供後面 SQL 使用 | |||
| val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) | |||
| args["reportPeriodStart"] = reportPeriodStart | |||
| args["reportPeriodEnd"] = reportPeriodEnd | |||
| // 4) 之後再用你原來的 stockCategorySql / itemCodeSql / storeLocationSql | |||
| args["reportPeriodEndExclusive"] = endExclusive | |||
| val stockCategorySql = buildMultiValueExactClause( | |||
| stockCategory, | |||
| "it.type", | |||
| "stockCategory", | |||
| args | |||
| ) | |||
| val itemCodeSql = buildMultiValueLikeClause( | |||
| itemCode, | |||
| "sl.itemCode", | |||
| "itemCode", | |||
| args | |||
| ) | |||
| // 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 | |||
| val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | |||
| args["storeLocation"] = "%$storeLocation%" | |||
| "AND (wh_in.code LIKE :storeLocation OR wh_out.code LIKE :storeLocation)" | |||
| "AND lot.storeLocation LIKE :storeLocation" | |||
| } else { | |||
| "" | |||
| } | |||
| val reportPeriodEndSql = "AND DATE(sl.date) <= :reportPeriodEnd" | |||
| val sql = """ | |||
| WITH base AS ( | |||
| SELECT | |||
| sl.id AS slId, | |||
| DATE(sl.date) AS trnDateRaw, | |||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, | |||
| CASE | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' | |||
| AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' | |||
| ELSE COALESCE(sl.type, '') | |||
| END AS trnRefNoRaw, | |||
| sl.itemId AS itemId, | |||
| sl.itemCode AS itemCode, | |||
| COALESCE(sl.inQty, 0) AS inQty, | |||
| COALESCE(sl.outQty, 0) AS outQty, | |||
| (COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS delta, | |||
| it.type AS stockSubCategory, | |||
| it.code AS itemNo, | |||
| it.name AS itemName, | |||
| uc.udfudesc AS unitOfMeasure, | |||
| lot.lotNo AS lotNo, | |||
| COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, | |||
| lot.storeLocation AS storeLocation, | |||
| COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, | |||
| COALESCE(TRIM(jo.code), '') AS jobOrderNo, | |||
| '' AS openingBalance, | |||
| '' AS cumStockIn, | |||
| '' AS cumStockOut, | |||
| '' AS currentBalance, | |||
| '' AS lastInDate, | |||
| '' AS lastOutDate, | |||
| '' AS reOrderLevel, | |||
| '' AS reOrderQty | |||
| FROM stock_ledger sl | |||
| LEFT JOIN stock_in_line sil | |||
| ON sl.stockInLineId = sil.id | |||
| AND sil.deleted = 0 | |||
| LEFT JOIN inventory_lot il_in | |||
| ON sil.inventoryLotId = il_in.id | |||
| AND il_in.deleted = 0 | |||
| LEFT JOIN stock_out_line sol | |||
| ON sl.stockOutLineId = sol.id | |||
| AND sol.deleted = 0 | |||
| LEFT JOIN inventory_lot_line ill_out | |||
| ON sol.inventoryLotLineId = ill_out.id | |||
| AND ill_out.deleted = 0 | |||
| LEFT JOIN inventory_lot il_out | |||
| ON ill_out.inventoryLotId = il_out.id | |||
| AND il_out.deleted = 0 | |||
| LEFT JOIN ( | |||
| SELECT | |||
| il.id AS lotId, | |||
| il.lotNo AS lotNo, | |||
| il.expiryDate AS expiryDate, | |||
| MAX(wh.code) AS storeLocation | |||
| FROM inventory_lot il | |||
| LEFT JOIN inventory_lot_line ill | |||
| ON ill.inventoryLotId = il.id | |||
| AND ill.deleted = 0 | |||
| LEFT JOIN warehouse wh | |||
| ON ill.warehouseId = wh.id | |||
| AND wh.deleted = 0 | |||
| GROUP BY | |||
| il.id, il.lotNo, il.expiryDate | |||
| ) lot | |||
| ON lot.lotId = COALESCE(il_in.id, il_out.id) | |||
| LEFT JOIN items it | |||
| ON sl.itemId = it.id | |||
| AND it.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON it.id = iu.itemId | |||
| AND iu.stockUnit = 1 | |||
| AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| -- 這兩個 alias 是為了配合你上面 storeLocationSql 的 wh_in / wh_out | |||
| LEFT JOIN inventory_lot_line ill_in | |||
| ON il_in.id = ill_in.inventoryLotId | |||
| AND ill_in.deleted = 0 | |||
| LEFT JOIN warehouse wh_in | |||
| ON ill_in.warehouseId = wh_in.id | |||
| AND wh_in.deleted = 0 | |||
| LEFT JOIN warehouse wh_out | |||
| ON ill_out.warehouseId = wh_out.id | |||
| AND wh_out.deleted = 0 | |||
| LEFT JOIN stock_out so | |||
| ON sol.stockOutId = so.id | |||
| AND so.deleted = 0 | |||
| LEFT JOIN pick_order_line pol | |||
| ON sol.pickOrderLineId = pol.id | |||
| AND pol.deleted = 0 | |||
| LEFT JOIN pick_order po_out | |||
| ON pol.poId = po_out.id | |||
| AND po_out.deleted = 0 | |||
| LEFT JOIN job_order jo_po | |||
| ON po_out.joId = jo_po.id | |||
| AND jo_po.deleted = 0 | |||
| LEFT JOIN delivery_order do | |||
| ON po_out.doId = do.id | |||
| AND do.deleted = 0 | |||
| LEFT JOIN stock_in si | |||
| ON sil.stockInId = si.id | |||
| AND si.deleted = 0 | |||
| LEFT JOIN job_order jo | |||
| ON sil.jobOrderId = jo.id | |||
| AND jo.deleted = 0 | |||
| LEFT JOIN purchase_order po | |||
| ON sil.purchaseOrderId = po.id | |||
| AND po.deleted = 0 | |||
| WHERE | |||
| sl.deleted = 0 | |||
| AND sl.itemCode IS NOT NULL | |||
| AND sl.itemCode <> '' | |||
| AND DATE(sl.date) <= :reportPeriodEnd | |||
| $stockCategorySql | |||
| $itemCodeSql | |||
| $storeLocationSql | |||
| AND lot.lotId IS NOT NULL | |||
| ), | |||
| opening AS ( | |||
| SELECT | |||
| itemCode, | |||
| COALESCE(SUM(delta), 0) AS openingBeforeStart | |||
| FROM base | |||
| WHERE trnDateRaw < :reportPeriodStart | |||
| GROUP BY itemCode | |||
| ), | |||
| period AS ( | |||
| SELECT | |||
| b.*, | |||
| COALESCE(o.openingBeforeStart, 0) AS openingBeforeStart | |||
| FROM base b | |||
| LEFT JOIN opening o | |||
| ON o.itemCode = b.itemCode | |||
| WHERE b.trnDateRaw BETWEEN :reportPeriodStart AND :reportPeriodEnd | |||
| ) | |||
| SELECT | |||
| stockSubCategory, | |||
| itemNo, | |||
| @@ -231,7 +87,7 @@ SELECT | |||
| unitOfMeasure, | |||
| lotNo, | |||
| expiryDate, | |||
| trnDate, | |||
| trnDate, | |||
| CASE trnRefNoRaw | |||
| WHEN 'OPEN' THEN '開倉' | |||
| WHEN 'NOR' THEN '出入倉' | |||
| @@ -255,39 +111,171 @@ SELECT | |||
| reOrderLevel, | |||
| reOrderQty, | |||
| -- jrxml 需要 String;負數括號顯示,無小數 | |||
| CASE WHEN COALESCE(inQty, 0) < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(COALESCE(inQty, 0), 0) END AS stockIn, | |||
| CASE WHEN COALESCE(outQty, 0) < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(COALESCE(outQty, 0), 0) END AS stockOut, | |||
| -- 累計存量(跨 lot:只用 itemCode 分區) | |||
| CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)) < 0 | |||
| THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)), 0), ')') | |||
| ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId), 0) END AS cumBalance, | |||
| -- 累計期初存量 = 本行累計 - 本行異動 | |||
| CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta) < 0 | |||
| THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta), 0), ')') | |||
| ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta, 0) END AS cumOpeningBal, | |||
| -- footer totals(同樣輸出 String) | |||
| CASE WHEN COALESCE(openingBeforeStart, 0) < 0 THEN CONCAT('(', FORMAT(-openingBeforeStart, 0), ')') ELSE FORMAT(COALESCE(openingBeforeStart, 0), 0) END AS totalCumOpeningBal, | |||
| CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockIn, | |||
| CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockOut, | |||
| CASE WHEN (openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)) < 0 | |||
| THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)), 0), ')') | |||
| ELSE FORMAT(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode), 0) END AS totalCumBalance | |||
| FROM period | |||
| CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn, | |||
| CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut, | |||
| CASE WHEN cumOpeningBalRaw < 0 | |||
| THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')') | |||
| ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal, | |||
| CASE WHEN bal < 0 | |||
| THEN CONCAT('(', FORMAT(-bal, 0), ')') | |||
| ELSE FORMAT(bal, 0) END AS cumBalance, | |||
| CASE WHEN totalCumOpeningBalRaw < 0 | |||
| THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')') | |||
| ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal, | |||
| CASE WHEN totalStockInRaw < 0 | |||
| THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')') | |||
| ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn, | |||
| CASE WHEN totalStockOutRaw < 0 | |||
| THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')') | |||
| ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut, | |||
| CASE WHEN totalCumBalanceRaw < 0 | |||
| THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')') | |||
| ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance | |||
| FROM ( | |||
| SELECT | |||
| x.*, | |||
| FIRST_VALUE(cumOpeningBalRaw) OVER ( | |||
| PARTITION BY itemCode ORDER BY trnDateRaw, slId | |||
| ) AS totalCumOpeningBalRaw, | |||
| SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw, | |||
| SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw, | |||
| FIRST_VALUE(bal) OVER ( | |||
| PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC | |||
| ) AS totalCumBalanceRaw | |||
| FROM ( | |||
| SELECT | |||
| sl.id AS slId, | |||
| DATE(sl.date) AS trnDateRaw, | |||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, | |||
| CASE | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' | |||
| AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' | |||
| ELSE COALESCE(sl.type, '') | |||
| END AS trnRefNoRaw, | |||
| sl.itemCode AS itemCode, | |||
| COALESCE(sl.inQty, 0) AS inQty, | |||
| COALESCE(sl.outQty, 0) AS outQty, | |||
| COALESCE(sl.balance, 0) AS bal, | |||
| COALESCE(sl.balance, 0) | |||
| - COALESCE(sl.inQty, 0) | |||
| + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, | |||
| it.type AS stockSubCategory, | |||
| it.code AS itemNo, | |||
| it.name AS itemName, | |||
| uc.udfudesc AS unitOfMeasure, | |||
| lot.lotNo AS lotNo, | |||
| COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, | |||
| lot.storeLocation AS storeLocation, | |||
| COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, | |||
| COALESCE(TRIM(jo.code), '') AS jobOrderNo, | |||
| '' AS openingBalance, | |||
| '' AS cumStockIn, | |||
| '' AS cumStockOut, | |||
| '' AS currentBalance, | |||
| '' AS lastInDate, | |||
| '' AS lastOutDate, | |||
| '' AS reOrderLevel, | |||
| '' AS reOrderQty | |||
| FROM stock_ledger sl | |||
| LEFT JOIN stock_in_line sil | |||
| ON sl.stockInLineId = sil.id | |||
| AND sil.deleted = 0 | |||
| LEFT JOIN inventory_lot il_in | |||
| ON sil.inventoryLotId = il_in.id | |||
| AND il_in.deleted = 0 | |||
| LEFT JOIN stock_out_line sol | |||
| ON sl.stockOutLineId = sol.id | |||
| AND sol.deleted = 0 | |||
| LEFT JOIN inventory_lot_line ill_out | |||
| ON sol.inventoryLotLineId = ill_out.id | |||
| AND ill_out.deleted = 0 | |||
| LEFT JOIN inventory_lot il_out | |||
| ON ill_out.inventoryLotId = il_out.id | |||
| AND il_out.deleted = 0 | |||
| LEFT JOIN ( | |||
| SELECT | |||
| il.id AS lotId, | |||
| il.lotNo AS lotNo, | |||
| il.expiryDate AS expiryDate, | |||
| MAX(wh.code) AS storeLocation | |||
| FROM inventory_lot il | |||
| LEFT JOIN inventory_lot_line ill | |||
| ON ill.inventoryLotId = il.id | |||
| AND ill.deleted = 0 | |||
| LEFT JOIN warehouse wh | |||
| ON ill.warehouseId = wh.id | |||
| AND wh.deleted = 0 | |||
| WHERE il.deleted = 0 | |||
| GROUP BY | |||
| il.id, il.lotNo, il.expiryDate | |||
| ) lot | |||
| ON lot.lotId = COALESCE(il_in.id, il_out.id) | |||
| LEFT JOIN items it | |||
| ON sl.itemId = it.id | |||
| AND it.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON it.id = iu.itemId | |||
| AND iu.stockUnit = 1 | |||
| AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| LEFT JOIN stock_out so | |||
| ON sol.stockOutId = so.id | |||
| AND so.deleted = 0 | |||
| LEFT JOIN pick_order_line pol | |||
| ON sol.pickOrderLineId = pol.id | |||
| AND pol.deleted = 0 | |||
| LEFT JOIN pick_order po_out | |||
| ON pol.poId = po_out.id | |||
| AND po_out.deleted = 0 | |||
| LEFT JOIN job_order jo_po | |||
| ON po_out.joId = jo_po.id | |||
| AND jo_po.deleted = 0 | |||
| LEFT JOIN delivery_order do | |||
| ON po_out.doId = do.id | |||
| AND do.deleted = 0 | |||
| LEFT JOIN stock_in si | |||
| ON sil.stockInId = si.id | |||
| AND si.deleted = 0 | |||
| LEFT JOIN job_order jo | |||
| ON sil.jobOrderId = jo.id | |||
| AND jo.deleted = 0 | |||
| LEFT JOIN purchase_order po | |||
| ON sil.purchaseOrderId = po.id | |||
| AND po.deleted = 0 | |||
| WHERE | |||
| sl.deleted = 0 | |||
| AND sl.itemCode IS NOT NULL | |||
| AND sl.itemCode <> '' | |||
| AND sl.date >= :reportPeriodStart | |||
| AND sl.date < :reportPeriodEndExclusive | |||
| $stockCategorySql | |||
| $itemCodeSql | |||
| $storeLocationSql | |||
| AND lot.lotId IS NOT NULL | |||
| ) x | |||
| ) y | |||
| ORDER BY | |||
| itemNo, | |||
| trnDateRaw, | |||
| slId, | |||
| lotNo | |||
| """.trimIndent() | |||
| val result = jdbcDao.queryForList(sql, args) | |||
| return result | |||
| return jdbcDao.queryForList(sql, args) | |||
| } | |||
| /** LIKE 多值工具方法 */ | |||
| @@ -327,4 +315,4 @@ ORDER BY | |||
| } | |||
| return "AND (${conditions.joinToString(" OR ")})" | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,419 @@ | |||
| package com.ffii.fpsms.modules.report.service | |||
| import com.ffii.core.support.JdbcDao | |||
| import org.springframework.stereotype.Service | |||
| import java.time.LocalDate | |||
| import java.time.format.DateTimeFormatter | |||
| /** | |||
| * 庫存批次現況(Stock Balance):永遠今天。 | |||
| * 現存讀 [inventory_lot_line](available、未過期、in-out > 0)。 | |||
| * 最後異動:有 inventoryLotLineId 的帳本用 MAX(id);缺的比 SIL/SOL 時間。 | |||
| * 單位均價/庫存總價值只填 root PO 為 PP/PF 的批(TRF 往回走);其他來源空白。 | |||
| */ | |||
| @Service | |||
| open class StockLotOnhandReportService( | |||
| private val jdbcDao: JdbcDao, | |||
| ) { | |||
| private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") | |||
| companion object { | |||
| /** Same TRF walk as stock-take variance: root stock-in / PO after transfers. */ | |||
| private const val LOT_ROOT_ORIGIN_CTE_SQL = """ | |||
| lot_trace AS ( | |||
| SELECT | |||
| il.id AS lotId, | |||
| il.stockInLineId AS silId, | |||
| 0 AS depth | |||
| FROM inventory_lot il | |||
| WHERE il.deleted = 0 | |||
| AND il.stockInLineId IS NOT NULL | |||
| UNION ALL | |||
| SELECT | |||
| lt.lotId AS lotId, | |||
| il_src.stockInLineId AS silId, | |||
| lt.depth + 1 AS depth | |||
| FROM lot_trace lt | |||
| INNER JOIN stock_in_line sil | |||
| ON sil.id = lt.silId | |||
| AND sil.deleted = 0 | |||
| INNER JOIN stock_transfer_record tr | |||
| ON tr.id = sil.stockTransferId | |||
| AND tr.deleted = 0 | |||
| INNER JOIN stock_out_line sol | |||
| ON sol.id = tr.stockOutLineId | |||
| AND sol.deleted = 0 | |||
| INNER JOIN inventory_lot_line ill_src | |||
| ON ill_src.id = sol.inventoryLotLineId | |||
| AND ill_src.deleted = 0 | |||
| INNER JOIN inventory_lot il_src | |||
| ON il_src.id = ill_src.inventoryLotId | |||
| AND il_src.deleted = 0 | |||
| WHERE lt.depth < 8 | |||
| AND il_src.stockInLineId IS NOT NULL | |||
| AND ( | |||
| UPPER(TRIM(COALESCE(sil.type, ''))) = 'TRF' | |||
| OR sil.stockTransferId IS NOT NULL | |||
| ) | |||
| ), | |||
| lot_root_origin AS ( | |||
| SELECT | |||
| lotId, | |||
| silId AS rootSilId | |||
| FROM ( | |||
| SELECT | |||
| lotId, | |||
| silId, | |||
| ROW_NUMBER() OVER (PARTITION BY lotId ORDER BY depth DESC) AS rn | |||
| FROM lot_trace | |||
| ) t | |||
| WHERE t.rn = 1 | |||
| )""" | |||
| private const val ROOT_STOCK_IN_JOIN_SQL = """ | |||
| LEFT JOIN lot_root_origin lro | |||
| ON lro.lotId = il.id | |||
| LEFT JOIN stock_in_line root_sil | |||
| ON root_sil.id = lro.rootSilId AND root_sil.deleted = 0 | |||
| LEFT JOIN purchase_order root_po | |||
| ON root_po.id = root_sil.purchaseOrderId AND root_po.deleted = 0 | |||
| """ | |||
| } | |||
| data class SearchResult( | |||
| val rows: List<Map<String, Any>>, | |||
| val stockDate: String, | |||
| ) | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ | |||
| fun search( | |||
| itemCode: String?, | |||
| storeId: String?, | |||
| warehouse: String?, | |||
| area: String?, | |||
| slot: String?, | |||
| lotNo: String?, | |||
| stockTakeSectionDescription: String?, | |||
| lotOrigin: String?, | |||
| ): SearchResult { | |||
| val asOfDateStr = LocalDate.now().format(dateFmt) | |||
| val masterArgs = HashMap<String, Any>() | |||
| val itemCodeSqlIt = buildMultiValueLikeClause(itemCode, "it.code", "itemCodeIt", masterArgs) | |||
| val lotNoSql = buildMultiValueLikeClause(lotNo, "il.lotNo", "lotNo", masterArgs) | |||
| val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") { | |||
| masterArgs["storeId"] = storeId.trim() | |||
| "AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')" | |||
| } else { | |||
| "" | |||
| } | |||
| val warehouseSql = if (!warehouse.isNullOrBlank() && warehouse.trim() != "All") { | |||
| buildMultiValueLikeClause(warehouse, "wh.warehouse", "warehousePart", masterArgs) | |||
| } else { | |||
| "" | |||
| } | |||
| val areaSql = if (!area.isNullOrBlank() && area.trim() != "All") { | |||
| buildMultiValueLikeClause(area, "wh.area", "areaPart", masterArgs) | |||
| } else { | |||
| "" | |||
| } | |||
| val slotSql = if (!slot.isNullOrBlank() && slot.trim() != "All") { | |||
| buildMultiValueLikeClause(slot, "wh.slot", "slotPart", masterArgs) | |||
| } else { | |||
| "" | |||
| } | |||
| val sectionDescSql = if ( | |||
| !stockTakeSectionDescription.isNullOrBlank() && | |||
| stockTakeSectionDescription.trim() != "All" | |||
| ) { | |||
| masterArgs["stockTakeSectionDescription"] = stockTakeSectionDescription.trim() | |||
| "AND COALESCE(wh.stockTakeSectionDescription, '') = :stockTakeSectionDescription" | |||
| } else { | |||
| "" | |||
| } | |||
| val lotOriginFilterSql = buildLotOriginFilterSql(lotOrigin) | |||
| val originJoinSql = ROOT_STOCK_IN_JOIN_SQL | |||
| val ctePrefix = "WITH RECURSIVE\n$LOT_ROOT_ORIGIN_CTE_SQL\n" | |||
| val liveLots = jdbcDao.queryForList( | |||
| """ | |||
| ${ctePrefix}SELECT | |||
| ill.id AS inventoryLotLineId, | |||
| COALESCE(it.code, '') AS itemNo, | |||
| COALESCE(it.name, '') AS itemName, | |||
| COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, | |||
| COALESCE(il.lotNo, '') AS lotNo, | |||
| COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, | |||
| COALESCE(wh.store_id, '') AS storeId, | |||
| COALESCE(wh.warehouse, '') AS warehousePart, | |||
| COALESCE(wh.area, '') AS areaPart, | |||
| COALESCE(wh.slot, '') AS slotPart, | |||
| (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS lotQtyRaw, | |||
| COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw, | |||
| UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) AS rootPoPrefix | |||
| FROM inventory_lot_line ill | |||
| INNER JOIN inventory_lot il | |||
| ON il.id = ill.inventoryLotId AND il.deleted = 0 | |||
| INNER JOIN items it | |||
| ON it.id = il.itemId AND it.deleted = 0 | |||
| INNER JOIN warehouse wh | |||
| ON wh.id = ill.warehouseId AND wh.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON iu.id = ill.stockItemUomId AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc | |||
| ON uc.id = iu.uomId | |||
| $originJoinSql | |||
| WHERE ill.deleted = 0 | |||
| AND it.code IS NOT NULL AND it.code <> '' | |||
| AND LOWER(COALESCE(ill.status, '')) = 'available' | |||
| AND (il.expiryDate IS NULL OR il.expiryDate >= CURRENT_DATE) | |||
| AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0 | |||
| $itemCodeSqlIt | |||
| $storeIdSql | |||
| $warehouseSql | |||
| $areaSql | |||
| $slotSql | |||
| $sectionDescSql | |||
| $lotNoSql | |||
| $lotOriginFilterSql | |||
| """.trimIndent(), | |||
| masterArgs, | |||
| ) | |||
| val lotIds = liveLots.map { toLong(it["inventoryLotLineId"]) }.filter { it > 0 } | |||
| val lastTrnByLot = HashMap<Long, Pair<String, String>>(lotIds.size * 2) | |||
| loadLastTrnFromLedger(lotIds, lastTrnByLot) | |||
| val missing = lotIds.distinct().filter { it !in lastTrnByLot } | |||
| if (missing.isNotEmpty()) { | |||
| val silSolHits = HashMap<Long, SilSolHit>(missing.size * 2) | |||
| fillLastTrnFromLotHeaderSil(missing, silSolHits) | |||
| fillLastTrnFromSilLine(missing, silSolHits) | |||
| fillLastTrnFromSol(missing, silSolHits) | |||
| for ((lotId, hit) in silSolHits) { | |||
| lastTrnByLot[lotId] = hit.date to hit.kind | |||
| } | |||
| } | |||
| val rows = assembleRows(liveLots, lastTrnByLot) | |||
| return SearchResult(rows = rows, stockDate = asOfDateStr) | |||
| } | |||
| private data class SilSolHit(val ts: String, val date: String, val kind: String) | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ | |||
| private fun loadLastTrnFromLedger(lotIds: List<Long>, out: MutableMap<Long, Pair<String, String>>) { | |||
| if (lotIds.isEmpty()) return | |||
| // idx_ledger_lot_date_id is (lot, date, id) — bad for MAX(id); force the lot-only index | |||
| // (InnoDB secondary includes PK id, so MAX(id) per lot is an index tail lookup). | |||
| for (chunk in lotIds.distinct().chunked(2000)) { | |||
| val rows = jdbcDao.queryForList( | |||
| """ | |||
| SELECT | |||
| sl.inventoryLotLineId, | |||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS lastTrnDate, | |||
| COALESCE(sl.inQty, 0) AS inQty, | |||
| COALESCE(sl.outQty, 0) AS outQty | |||
| FROM stock_ledger sl | |||
| INNER JOIN ( | |||
| SELECT inventoryLotLineId, MAX(id) AS maxId | |||
| FROM stock_ledger FORCE INDEX (idx_ledger_inventoryLotLineId) | |||
| WHERE deleted = 0 | |||
| AND inventoryLotLineId IN (:lotIds) | |||
| GROUP BY inventoryLotLineId | |||
| ) t ON t.maxId = sl.id | |||
| """.trimIndent(), | |||
| mapOf("lotIds" to chunk), | |||
| ) | |||
| for (r in rows) { | |||
| val lotId = toLong(r["inventoryLotLineId"]) | |||
| if (lotId <= 0) continue | |||
| val date = r["lastTrnDate"]?.toString().orEmpty() | |||
| out[lotId] = date to lastTrnType(toDouble(r["inQty"]), toDouble(r["outQty"])) | |||
| } | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ | |||
| private fun fillLastTrnFromLotHeaderSil(missing: List<Long>, hits: MutableMap<Long, SilSolHit>): Int { | |||
| var n = 0 | |||
| for (chunk in missing.chunked(800)) { | |||
| val rows = jdbcDao.queryForList( | |||
| """ | |||
| SELECT | |||
| ill.id AS lotLineId, | |||
| DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d %H:%i:%s') AS lastTs, | |||
| DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d') AS lastTrnDate | |||
| FROM inventory_lot_line ill | |||
| INNER JOIN inventory_lot il | |||
| ON il.id = ill.inventoryLotId AND il.deleted = 0 | |||
| INNER JOIN stock_in_line sil | |||
| ON sil.id = il.stockInLineId AND sil.deleted = 0 | |||
| WHERE ill.deleted = 0 | |||
| AND ill.id IN (:lotIds) | |||
| """.trimIndent(), | |||
| mapOf("lotIds" to chunk), | |||
| ) | |||
| n += rows.size | |||
| applySilSolHits(rows, hits, "入庫") | |||
| } | |||
| return n | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ | |||
| private fun fillLastTrnFromSilLine(missing: List<Long>, hits: MutableMap<Long, SilSolHit>): Int { | |||
| var n = 0 | |||
| for (chunk in missing.chunked(800)) { | |||
| val rows = jdbcDao.queryForList( | |||
| """ | |||
| SELECT | |||
| sil.inventoryLotLineId AS lotLineId, | |||
| DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d %H:%i:%s') AS lastTs, | |||
| DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d') AS lastTrnDate | |||
| FROM stock_in_line sil | |||
| WHERE sil.deleted = 0 | |||
| AND sil.inventoryLotLineId IN (:lotIds) | |||
| """.trimIndent(), | |||
| mapOf("lotIds" to chunk), | |||
| ) | |||
| n += rows.size | |||
| applySilSolHits(rows, hits, "入庫") | |||
| } | |||
| return n | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ | |||
| private fun fillLastTrnFromSol(missing: List<Long>, hits: MutableMap<Long, SilSolHit>): Int { | |||
| var n = 0 | |||
| for (chunk in missing.chunked(800)) { | |||
| val rows = jdbcDao.queryForList( | |||
| """ | |||
| SELECT | |||
| sol.inventoryLotLineId AS lotLineId, | |||
| DATE_FORMAT(COALESCE(sol.pickTime, sol.modified, sol.created), '%Y-%m-%d %H:%i:%s') AS lastTs, | |||
| DATE_FORMAT(COALESCE(sol.pickTime, sol.modified, sol.created), '%Y-%m-%d') AS lastTrnDate | |||
| FROM stock_out_line sol | |||
| WHERE sol.deleted = 0 | |||
| AND sol.inventoryLotLineId IN (:lotIds) | |||
| """.trimIndent(), | |||
| mapOf("lotIds" to chunk), | |||
| ) | |||
| n += rows.size | |||
| applySilSolHits(rows, hits, "出庫") | |||
| } | |||
| return n | |||
| } | |||
| private fun applySilSolHits(rows: List<Map<String, Any>>, hits: MutableMap<Long, SilSolHit>, kind: String) { | |||
| for (r in rows) { | |||
| val lotId = toLong(r["lotLineId"]) | |||
| val ts = r["lastTs"]?.toString().orEmpty() | |||
| if (lotId <= 0 || ts.isBlank()) continue | |||
| val date = r["lastTrnDate"]?.toString().orEmpty() | |||
| val prev = hits[lotId] | |||
| val newer = prev == null || | |||
| ts > prev.ts || | |||
| (ts == prev.ts && kind == "出庫" && prev.kind != "出庫") | |||
| if (newer) hits[lotId] = SilSolHit(ts, date, kind) | |||
| } | |||
| } | |||
| private fun lastTrnType(inQty: Double, outQty: Double): String = | |||
| when { | |||
| outQty > 0 && inQty <= 0 -> "出庫" | |||
| inQty > 0 && outQty <= 0 -> "入庫" | |||
| inQty > 0 && outQty > 0 -> if (outQty >= inQty) "出庫" else "入庫" | |||
| else -> "" | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ | |||
| private fun assembleRows( | |||
| liveLots: List<Map<String, Any>>, | |||
| lastTrnByLot: Map<Long, Pair<String, String>>, | |||
| ): List<Map<String, Any>> { | |||
| val tot = HashMap<String, Double>() | |||
| for (r in liveLots) { | |||
| val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" | |||
| tot[key] = (tot[key] ?: 0.0) + toDouble(r["lotQtyRaw"]) | |||
| } | |||
| val out = ArrayList<Map<String, Any>>(liveLots.size) | |||
| for (r in liveLots) { | |||
| val lotId = toLong(r["inventoryLotLineId"]) | |||
| val lotQty = toDouble(r["lotQtyRaw"]) | |||
| val avg = toDouble(r["avgUnitPriceRaw"]) | |||
| val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" | |||
| val last = lastTrnByLot[lotId] | |||
| val origin = r["rootPoPrefix"]?.toString().orEmpty().uppercase() | |||
| val showPrice = origin == "PP" || origin == "PF" | |||
| val row = HashMap<String, Any>(22) | |||
| row["inventoryLotLineId"] = lotId.toString() | |||
| row["itemNo"] = r["itemNo"] ?: "" | |||
| row["itemName"] = r["itemName"] ?: "" | |||
| row["unitOfMeasure"] = r["unitOfMeasure"] ?: "" | |||
| row["lotNo"] = r["lotNo"] ?: "" | |||
| row["expiryDate"] = r["expiryDate"] ?: "" | |||
| row["storeId"] = r["storeId"] ?: "" | |||
| row["warehousePart"] = r["warehousePart"] ?: "" | |||
| row["areaPart"] = r["areaPart"] ?: "" | |||
| row["slotPart"] = r["slotPart"] ?: "" | |||
| row["lotQtyRaw"] = lotQty | |||
| row["totalQtyRaw"] = tot[key] ?: 0.0 | |||
| row["avgUnitPriceRaw"] = if (showPrice) avg else "" | |||
| row["stockValueRaw"] = if (showPrice) avg * lotQty else "" | |||
| row["lastTrnDate"] = last?.first ?: "" | |||
| row["lastTrnType"] = last?.second ?: "" | |||
| out.add(row) | |||
| } | |||
| out.sortWith( | |||
| compareBy<Map<String, Any>> { it["itemNo"]?.toString().orEmpty() } | |||
| .thenBy { it["unitOfMeasure"]?.toString().orEmpty() } | |||
| .thenBy { it["lotNo"]?.toString().orEmpty() } | |||
| .thenBy { it["storeId"]?.toString().orEmpty() } | |||
| .thenBy { it["warehousePart"]?.toString().orEmpty() } | |||
| .thenBy { it["areaPart"]?.toString().orEmpty() } | |||
| .thenBy { it["slotPart"]?.toString().orEmpty() } | |||
| .thenBy { it["inventoryLotLineId"]?.toString().orEmpty() }, | |||
| ) | |||
| return out | |||
| } | |||
| private fun toDouble(v: Any?): Double { | |||
| if (v == null) return 0.0 | |||
| if (v is Number) return v.toDouble() | |||
| return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0 | |||
| } | |||
| private fun toLong(v: Any?): Long { | |||
| if (v == null) return 0L | |||
| if (v is Number) return v.toLong() | |||
| return v.toString().toLongOrNull() ?: 0L | |||
| } | |||
| /** PP/PF = root PO code prefix after TRF walk; other = not PP/PF (ADJ, TRF of other origin, JO, OPEN, …). */ | |||
| private fun buildLotOriginFilterSql(lotOrigin: String?): String { | |||
| val v = lotOrigin?.trim().orEmpty() | |||
| if (v.isBlank() || v.equals("All", ignoreCase = true)) return "" | |||
| return when (v.lowercase()) { | |||
| "pp" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PP'" | |||
| "pf" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PF'" | |||
| "other" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) NOT IN ('PP', 'PF')" | |||
| else -> "" | |||
| } | |||
| } | |||
| private fun buildMultiValueLikeClause( | |||
| paramValue: String?, | |||
| columnName: String, | |||
| paramPrefix: String, | |||
| args: MutableMap<String, Any>, | |||
| ): String { | |||
| if (paramValue.isNullOrBlank()) return "" | |||
| val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } | |||
| if (values.isEmpty()) return "" | |||
| val conditions = values.mapIndexed { index, value -> | |||
| val paramName = "${paramPrefix}_$index" | |||
| args[paramName] = "%$value%" | |||
| "$columnName LIKE :$paramName" | |||
| } | |||
| return "AND (${conditions.joinToString(" OR ")})" | |||
| } | |||
| } | |||
| @@ -0,0 +1,116 @@ | |||
| package com.ffii.fpsms.modules.report.web | |||
| import com.ffii.fpsms.modules.report.service.DoInventoryUomMismatchReportService | |||
| import org.apache.poi.ss.usermodel.BorderStyle | |||
| import org.apache.poi.ss.usermodel.FillPatternType | |||
| import org.apache.poi.ss.usermodel.HorizontalAlignment | |||
| import org.apache.poi.ss.usermodel.IndexedColors | |||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||
| import org.apache.poi.ss.util.WorkbookUtil | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import org.springframework.http.HttpHeaders | |||
| import org.springframework.http.HttpStatus | |||
| import org.springframework.http.MediaType | |||
| import org.springframework.http.ResponseEntity | |||
| import org.springframework.web.bind.annotation.GetMapping | |||
| import org.springframework.web.bind.annotation.RequestMapping | |||
| import org.springframework.web.bind.annotation.RequestParam | |||
| import org.springframework.web.bind.annotation.RestController | |||
| import java.io.ByteArrayOutputStream | |||
| @RestController | |||
| @RequestMapping("/report") | |||
| class DoInventoryUomMismatchReportController( | |||
| private val doInventoryUomMismatchReportService: DoInventoryUomMismatchReportService, | |||
| ) { | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ | |||
| @GetMapping("/print-do-inventory-uom-mismatch-excel") | |||
| fun exportExcel( | |||
| @RequestParam(required = false) deliveryDate: String?, | |||
| @RequestParam(required = false) storeId: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val rows = try { | |||
| doInventoryUomMismatchReportService.searchDoInventoryUomMismatch(deliveryDate, storeId) | |||
| } catch (ex: IllegalArgumentException) { | |||
| return ResponseEntity.status(HttpStatus.BAD_REQUEST).build() | |||
| } | |||
| val bytes = buildExcel(rows) | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.parseMediaType( | |||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |||
| ) | |||
| // ASCII only — Tomcat rejects non-Latin-1 in Content-Disposition | |||
| setContentDispositionFormData("attachment", "DoInventoryUomMismatchReport.xlsx") | |||
| } | |||
| return ResponseEntity(bytes, headers, HttpStatus.OK) | |||
| } | |||
| private fun buildExcel(rows: List<Map<String, Any?>>): ByteArray { | |||
| val workbook = XSSFWorkbook() | |||
| val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName("DO_UOM_mismatch")) | |||
| val headerStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| val font = workbook.createFont().apply { bold = true } | |||
| setFont(font) | |||
| } | |||
| val textStyle = workbook.createCellStyle().apply { | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val columns = listOf( | |||
| "deliveryDate" to "預計送貨日期", | |||
| "doCode" to "送貨單號", | |||
| "supplierCode" to "供應商編號", | |||
| "supplierName" to "供應商名稱", | |||
| "itemCode" to "貨品編號", | |||
| "itemName" to "貨品名稱", | |||
| "doQty" to "送貨單數量", | |||
| "doUom" to "送貨單單位", | |||
| "mismatchLotNo" to "不符批號", | |||
| "mismatchLotQty" to "不符批數量", | |||
| "mismatchLotUom" to "不符批單位", | |||
| ) | |||
| var r = 0 | |||
| val headerRow = sheet.createRow(r++) | |||
| columns.forEachIndexed { i, (_, label) -> | |||
| headerRow.createCell(i).apply { | |||
| setCellValue(label) | |||
| cellStyle = headerStyle | |||
| } | |||
| } | |||
| for (row in rows) { | |||
| val excelRow = sheet.createRow(r++) | |||
| columns.forEachIndexed { i, (key, _) -> | |||
| val v = row[key] | |||
| val cell = excelRow.createCell(i) | |||
| cell.cellStyle = textStyle | |||
| when (v) { | |||
| null -> cell.setCellValue("") | |||
| is Number -> cell.setCellValue(v.toDouble()) | |||
| else -> cell.setCellValue(v.toString()) | |||
| } | |||
| } | |||
| } | |||
| val widths = intArrayOf(14, 16, 12, 18, 14, 28, 12, 14, 18, 12, 14) | |||
| widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | |||
| val out = ByteArrayOutputStream() | |||
| workbook.write(out) | |||
| workbook.close() | |||
| return out.toByteArray() | |||
| } | |||
| } | |||
| @@ -21,6 +21,7 @@ class ItemQcFailReportController( | |||
| private val itemQcFailReportService: ItemQcFailReportService, | |||
| ) { | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ | |||
| @GetMapping("/print-item-qc-fail") | |||
| fun generateItemQcFailReport( | |||
| @RequestParam(required = false) stockCategory: String?, | |||
| @@ -29,7 +30,7 @@ class ItemQcFailReportController( | |||
| @RequestParam(required = false) lastInDateEnd: String?, | |||
| @RequestParam(required = false) qcType: String?, | |||
| @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, | |||
| @RequestParam(required = false, defaultValue = "false") includeOther: String?, | |||
| @RequestParam(required = false, defaultValue = "true") includeOther: String?, | |||
| @RequestParam(required = false, defaultValue = "all") measurableScope: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val dbData = itemQcFailReportService.searchItemQcFailReport( | |||
| @@ -70,6 +71,7 @@ class ItemQcFailReportController( | |||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ | |||
| @GetMapping("/print-item-qc-fail-excel") | |||
| fun exportItemQcFailReportExcel( | |||
| @RequestParam(required = false) stockCategory: String?, | |||
| @@ -78,7 +80,7 @@ class ItemQcFailReportController( | |||
| @RequestParam(required = false) lastInDateEnd: String?, | |||
| @RequestParam(required = false) qcType: String?, | |||
| @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, | |||
| @RequestParam(required = false, defaultValue = "false") includeOther: String?, | |||
| @RequestParam(required = false, defaultValue = "true") includeOther: String?, | |||
| @RequestParam(required = false, defaultValue = "all") measurableScope: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val dbData = itemQcFailReportService.searchItemQcFailReport( | |||
| @@ -297,7 +299,7 @@ class ItemQcFailReportController( | |||
| "不合格數量", | |||
| "實測值", | |||
| "備註", | |||
| "訂單/工單" | |||
| "訂單/工單", | |||
| ) | |||
| run { | |||
| @@ -321,12 +323,16 @@ class ItemQcFailReportController( | |||
| fun writeNumber(col: Int, value: Any?) { | |||
| val raw = value?.toString()?.trim() ?: "" | |||
| val cleaned = raw.removeSuffix(".") | |||
| // SQL FORMAT() may emit thousand separators (e.g. "1,021") — strip before parse. | |||
| val cleaned = raw | |||
| .replace(",", "") | |||
| .replace(" ", "") | |||
| .removeSuffix(".") | |||
| val bd = cleaned.toBigDecimalOrNull() | |||
| val cell = row.createCell(col) | |||
| if (bd == null) { | |||
| cell.setCellValue(cleaned) | |||
| cell.setCellValue(raw) | |||
| cell.cellStyle = textStyle | |||
| } else { | |||
| val stripped = bd.stripTrailingZeros() | |||
| @@ -14,10 +14,10 @@ import org.apache.poi.ss.usermodel.HorizontalAlignment | |||
| import org.apache.poi.ss.usermodel.IndexedColors | |||
| import org.apache.poi.ss.usermodel.Row | |||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||
| import org.apache.poi.ss.usermodel.Workbook | |||
| import org.apache.poi.ss.util.CellRangeAddress | |||
| import org.apache.poi.ss.util.WorkbookUtil | |||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import org.apache.poi.xssf.streaming.SXSSFWorkbook | |||
| import java.io.ByteArrayOutputStream | |||
| @RestController | |||
| @@ -27,66 +27,66 @@ class StockLedgerReportController( | |||
| private val stockLedgerReportService: StockLedgerReportService, | |||
| ) { | |||
| private data class ExcelStyles( | |||
| val title: XSSFCellStyle, | |||
| val subtitle: XSSFCellStyle, | |||
| val header: XSSFCellStyle, | |||
| val text: XSSFCellStyle, | |||
| val center: XSSFCellStyle, | |||
| val int: XSSFCellStyle, | |||
| val dash: XSSFCellStyle, | |||
| val sumQty: XSSFCellStyle, | |||
| val sumLabel: XSSFCellStyle, | |||
| val sumEmpty: XSSFCellStyle, | |||
| val sumHidden: XSSFCellStyle, | |||
| val title: CellStyle, | |||
| val subtitle: CellStyle, | |||
| val header: CellStyle, | |||
| val text: CellStyle, | |||
| val center: CellStyle, | |||
| val int: CellStyle, | |||
| val dash: CellStyle, | |||
| val sumQty: CellStyle, | |||
| val sumLabel: CellStyle, | |||
| val sumEmpty: CellStyle, | |||
| val sumHidden: CellStyle, | |||
| ) | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ | |||
| @GetMapping("/print-stock-ledger") | |||
| fun generateStockLedgerReport( | |||
| @RequestParam(required = false) stockCategory: String?, | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false) storeLocation: String?, | |||
| // URL 參數名仍然是 lastInDateStart / lastInDateEnd | |||
| @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, | |||
| @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val parameters = mutableMapOf<String, Any>() | |||
| parameters["stockCategory"] = stockCategory ?: "All" | |||
| parameters["stockSubCategory"] = stockCategory ?: "All" | |||
| parameters["itemNo"] = itemCode ?: "All" | |||
| parameters["year"] = LocalDate.now().year.toString() | |||
| parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) | |||
| parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | |||
| fun generateStockLedgerReport( | |||
| @RequestParam(required = false) stockCategory: String?, | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false) storeLocation: String?, | |||
| @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, | |||
| @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val parameters = mutableMapOf<String, Any>() | |||
| parameters["storeLocation"] = storeLocation ?: "" | |||
| parameters["balanceFilterStart"] = "" | |||
| parameters["balanceFilterEnd"] = "" | |||
| parameters["reportPeriodStart"] = reportPeriodStart ?: "" | |||
| parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" | |||
| parameters["stockCategory"] = stockCategory ?: "All" | |||
| parameters["stockSubCategory"] = stockCategory ?: "All" | |||
| parameters["itemNo"] = itemCode ?: "All" | |||
| parameters["year"] = LocalDate.now().year.toString() | |||
| parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) | |||
| parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | |||
| parameters["storeLocation"] = storeLocation ?: "" | |||
| parameters["balanceFilterStart"] = "" | |||
| parameters["balanceFilterEnd"] = "" | |||
| parameters["reportPeriodStart"] = reportPeriodStart ?: "" | |||
| parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" | |||
| val dbData = stockLedgerReportService.searchStockLedgerReport( | |||
| stockCategory = stockCategory, | |||
| itemCode = itemCode, | |||
| storeLocation = storeLocation, | |||
| reportPeriodStart = reportPeriodStart, | |||
| reportPeriodEnd = reportPeriodEnd, | |||
| ) | |||
| val dbData = stockLedgerReportService.searchStockLedgerReport( | |||
| stockCategory = stockCategory, | |||
| itemCode = itemCode, | |||
| storeLocation = storeLocation, | |||
| reportPeriodStart = reportPeriodStart, | |||
| reportPeriodEnd = reportPeriodEnd, | |||
| ) | |||
| val pdfBytes = reportService.createPdfResponse( | |||
| "/jasper/StockLedgarReport.jrxml", | |||
| parameters, | |||
| dbData | |||
| ) | |||
| val pdfBytes = reportService.createPdfResponse( | |||
| "/jasper/StockLedgarReport.jrxml", | |||
| parameters, | |||
| dbData, | |||
| ) | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.APPLICATION_PDF | |||
| setContentDispositionFormData("attachment", "StockLedgerReport.pdf") | |||
| set("filename", "StockLedgerReport.pdf") | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.APPLICATION_PDF | |||
| setContentDispositionFormData("attachment", "StockLedgerReport.pdf") | |||
| set("filename", "StockLedgerReport.pdf") | |||
| } | |||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | |||
| } | |||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ | |||
| @GetMapping("/print-stock-ledger-excel") | |||
| fun exportStockLedgerReportExcel( | |||
| @RequestParam(required = false) stockCategory: String?, | |||
| @@ -119,8 +119,8 @@ fun generateStockLedgerReport( | |||
| return ResponseEntity(excelBytes, headers, HttpStatus.OK) | |||
| } | |||
| private fun createStyles(workbook: XSSFWorkbook): ExcelStyles { | |||
| val titleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| private fun createStyles(workbook: Workbook): ExcelStyles { | |||
| val titleStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| val font = workbook.createFont().apply { | |||
| @@ -129,7 +129,7 @@ fun generateStockLedgerReport( | |||
| } | |||
| setFont(font) | |||
| } | |||
| val subtitleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| val subtitleStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| val font = workbook.createFont().apply { | |||
| @@ -138,7 +138,7 @@ fun generateStockLedgerReport( | |||
| } | |||
| setFont(font) | |||
| } | |||
| val headerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| val headerStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||
| @@ -150,7 +150,7 @@ fun generateStockLedgerReport( | |||
| val font = workbook.createFont().apply { bold = true } | |||
| setFont(font) | |||
| } | |||
| val textStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| val textStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| @@ -158,7 +158,7 @@ fun generateStockLedgerReport( | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val centerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| val centerStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| @@ -166,7 +166,7 @@ fun generateStockLedgerReport( | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val intStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| val intStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| @@ -176,7 +176,7 @@ fun generateStockLedgerReport( | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| } | |||
| val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| val dashStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| @@ -184,7 +184,7 @@ fun generateStockLedgerReport( | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val sumQty = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| val sumQty = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| @@ -196,7 +196,7 @@ fun generateStockLedgerReport( | |||
| val font = workbook.createFont().apply { bold = true } | |||
| setFont(font) | |||
| } | |||
| val sumLabel = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| val sumLabel = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THICK | |||
| @@ -206,7 +206,7 @@ fun generateStockLedgerReport( | |||
| val font = workbook.createFont().apply { bold = true } | |||
| setFont(font) | |||
| } | |||
| val sumEmpty = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| val sumEmpty = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THICK | |||
| @@ -214,7 +214,7 @@ fun generateStockLedgerReport( | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val sumHidden = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| val sumHidden = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THICK | |||
| @@ -239,7 +239,7 @@ fun generateStockLedgerReport( | |||
| ) | |||
| } | |||
| private fun setTextCell(row: Row, col: Int, value: Any?, style: XSSFCellStyle) { | |||
| private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { | |||
| row.createCell(col).apply { | |||
| setCellValue(value?.toString() ?: "") | |||
| cellStyle = style | |||
| @@ -259,8 +259,8 @@ fun generateStockLedgerReport( | |||
| row: Row, | |||
| col: Int, | |||
| value: Any?, | |||
| intStyle: XSSFCellStyle, | |||
| dashStyle: XSSFCellStyle, | |||
| intStyle: CellStyle, | |||
| dashStyle: CellStyle, | |||
| ) { | |||
| val cell = row.createCell(col) | |||
| val parsed = parseSignedNumber(value) | |||
| @@ -279,131 +279,160 @@ fun generateStockLedgerReport( | |||
| } | |||
| } | |||
| /** | |||
| * SXSSF keeps only a sliding window of rows in memory to avoid OOM on large exports. | |||
| */ | |||
| private fun createStockLedgerExcel( | |||
| dbData: List<Map<String, Any>>, | |||
| reportPeriodStart: String, | |||
| reportPeriodEnd: String, | |||
| ): ByteArray { | |||
| val workbook = XSSFWorkbook() | |||
| val styles = createStyles(workbook) | |||
| val reportTitle = "庫存明細報告" | |||
| val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) | |||
| val headers = listOf( | |||
| "貨品編號", "貨品名稱", "單位", | |||
| "出入賬日期", "類型", "批號", "到期日", | |||
| "纍計期初存量", "入庫", "出庫", "纍計存量", | |||
| "參考編號", "存貨位置", | |||
| ) | |||
| val totalColumns = headers.size | |||
| var rowIndex = 0 | |||
| val workbook = SXSSFWorkbook(100) | |||
| workbook.setCompressTempFiles(true) | |||
| try { | |||
| val styles = createStyles(workbook) | |||
| val reportTitle = "庫存明細報告" | |||
| val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) | |||
| val titleRow = sheet.createRow(rowIndex++) | |||
| titleRow.createCell(0).apply { | |||
| setCellValue(reportTitle) | |||
| cellStyle = styles.title | |||
| } | |||
| sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) | |||
| val reportDateTime = | |||
| LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + | |||
| "(" + | |||
| LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + | |||
| ")" | |||
| val subtitleRow = sheet.createRow(rowIndex++) | |||
| subtitleRow.createCell(0).apply { | |||
| setCellValue("報告日期:$reportDateTime") | |||
| cellStyle = styles.subtitle | |||
| } | |||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) | |||
| subtitleRow.createCell(5).apply { | |||
| setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") | |||
| cellStyle = styles.subtitle | |||
| } | |||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) | |||
| sheet.createRow(rowIndex++) | |||
| val headers = listOf( | |||
| "貨品編號", "貨品名稱", "單位", | |||
| "出入賬日期", "類型", "批號", "到期日", | |||
| "纍計期初存量", "入庫", "出庫", "纍計存量", | |||
| "參考編號", "存貨位置", | |||
| ) | |||
| val totalColumns = headers.size | |||
| var rowIndex = 0 | |||
| val headerRowIndex = rowIndex | |||
| val headerRow = sheet.createRow(rowIndex++) | |||
| headers.forEachIndexed { i, h -> | |||
| headerRow.createCell(i).apply { | |||
| setCellValue(h) | |||
| cellStyle = styles.header | |||
| val titleRow = sheet.createRow(rowIndex++) | |||
| titleRow.createCell(0).apply { | |||
| setCellValue(reportTitle) | |||
| cellStyle = styles.title | |||
| } | |||
| } | |||
| sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) | |||
| fun addItemSummaryRow(itemNo: String, itemName: String, uom: String, totalIn: Any?, totalOut: Any?, totalBal: Any?) { | |||
| val r = sheet.createRow(rowIndex++) | |||
| r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden } | |||
| r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden } | |||
| r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden } | |||
| for (c in 3 until totalColumns) { | |||
| r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty } | |||
| val reportDateTime = | |||
| LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + | |||
| "(" + | |||
| LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + | |||
| ")" | |||
| val subtitleRow = sheet.createRow(rowIndex++) | |||
| subtitleRow.createCell(0).apply { | |||
| setCellValue("報告日期:$reportDateTime") | |||
| cellStyle = styles.subtitle | |||
| } | |||
| // totals should align with numeric columns (shift right by 1) | |||
| r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel } | |||
| setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash) | |||
| setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash) | |||
| setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash) | |||
| } | |||
| if (dbData.isEmpty()) { | |||
| val r = sheet.createRow(rowIndex++) | |||
| for (c in 0 until totalColumns) { | |||
| r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } | |||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) | |||
| subtitleRow.createCell(5).apply { | |||
| setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") | |||
| cellStyle = styles.subtitle | |||
| } | |||
| } else { | |||
| var currentItemNo: String? = null | |||
| var currentItemName = "" | |||
| var currentUom = "" | |||
| var lastTotals: Triple<Any?, Any?, Any?> = Triple(null, null, null) | |||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) | |||
| sheet.createRow(rowIndex++) | |||
| dbData.forEach { m -> | |||
| val itemNo = m["itemNo"]?.toString().orEmpty() | |||
| val itemName = m["itemName"]?.toString().orEmpty() | |||
| val uom = m["unitOfMeasure"]?.toString().orEmpty() | |||
| val headerRowIndex = rowIndex | |||
| val headerRow = sheet.createRow(rowIndex++) | |||
| headers.forEachIndexed { i, h -> | |||
| headerRow.createCell(i).apply { | |||
| setCellValue(h) | |||
| cellStyle = styles.header | |||
| } | |||
| } | |||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||
| addItemSummaryRow(currentItemNo!!, currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third) | |||
| sheet.createRow(rowIndex++) | |||
| fun addItemSummaryRow( | |||
| itemNo: String, | |||
| itemName: String, | |||
| uom: String, | |||
| totalIn: Any?, | |||
| totalOut: Any?, | |||
| totalBal: Any?, | |||
| ) { | |||
| val r = sheet.createRow(rowIndex++) | |||
| r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden } | |||
| r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden } | |||
| r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden } | |||
| for (c in 3 until totalColumns) { | |||
| r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty } | |||
| } | |||
| r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel } | |||
| setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash) | |||
| setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash) | |||
| setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash) | |||
| } | |||
| if (dbData.isEmpty()) { | |||
| val r = sheet.createRow(rowIndex++) | |||
| setTextCell(r, 0, itemNo, styles.text) | |||
| setTextCell(r, 1, itemName, styles.text) | |||
| setTextCell(r, 2, uom, styles.center) | |||
| setTextCell(r, 3, m["trnDate"], styles.center) | |||
| val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t -> | |||
| if (t.equals("Expiry", ignoreCase = true)) "過期" else t | |||
| for (c in 0 until totalColumns) { | |||
| r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } | |||
| } | |||
| setTextCell(r, 4, typeText, styles.center) | |||
| setTextCell(r, 5, m["lotNo"], styles.text) | |||
| setTextCell(r, 6, m["expiryDate"], styles.center) | |||
| setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash) | |||
| setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash) | |||
| setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash) | |||
| setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash) | |||
| setTextCell(r, 11, m["orderRefNo"], styles.text) | |||
| setTextCell(r, 12, m["storeLocation"], styles.center) | |||
| } else { | |||
| var currentItemNo: String? = null | |||
| var currentItemName = "" | |||
| var currentUom = "" | |||
| var lastTotals: Triple<Any?, Any?, Any?> = Triple(null, null, null) | |||
| dbData.forEach { m -> | |||
| val itemNo = m["itemNo"]?.toString().orEmpty() | |||
| val itemName = m["itemName"]?.toString().orEmpty() | |||
| val uom = m["unitOfMeasure"]?.toString().orEmpty() | |||
| currentItemNo = itemNo | |||
| currentItemName = itemName | |||
| currentUom = uom | |||
| lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"]) | |||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||
| addItemSummaryRow( | |||
| currentItemNo!!, | |||
| currentItemName, | |||
| currentUom, | |||
| lastTotals.first, | |||
| lastTotals.second, | |||
| lastTotals.third, | |||
| ) | |||
| sheet.createRow(rowIndex++) | |||
| } | |||
| val r = sheet.createRow(rowIndex++) | |||
| setTextCell(r, 0, itemNo, styles.text) | |||
| setTextCell(r, 1, itemName, styles.text) | |||
| setTextCell(r, 2, uom, styles.center) | |||
| setTextCell(r, 3, m["trnDate"], styles.center) | |||
| val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t -> | |||
| if (t.equals("Expiry", ignoreCase = true)) "過期" else t | |||
| } | |||
| setTextCell(r, 4, typeText, styles.center) | |||
| setTextCell(r, 5, m["lotNo"], styles.text) | |||
| setTextCell(r, 6, m["expiryDate"], styles.center) | |||
| setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash) | |||
| setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash) | |||
| setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash) | |||
| setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash) | |||
| setTextCell(r, 11, m["orderRefNo"], styles.text) | |||
| setTextCell(r, 12, m["storeLocation"], styles.center) | |||
| currentItemNo = itemNo | |||
| currentItemName = itemName | |||
| currentUom = uom | |||
| lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"]) | |||
| } | |||
| addItemSummaryRow( | |||
| currentItemNo ?: "", | |||
| currentItemName, | |||
| currentUom, | |||
| lastTotals.first, | |||
| lastTotals.second, | |||
| lastTotals.third, | |||
| ) | |||
| } | |||
| addItemSummaryRow(currentItemNo ?: "", currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third) | |||
| } | |||
| val lastRowIndex = rowIndex - 1 | |||
| if (lastRowIndex >= headerRowIndex) { | |||
| sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) | |||
| } | |||
| val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12) | |||
| widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | |||
| val lastRowIndex = rowIndex - 1 | |||
| if (lastRowIndex >= headerRowIndex) { | |||
| sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) | |||
| val out = ByteArrayOutputStream() | |||
| workbook.write(out) | |||
| return out.toByteArray() | |||
| } finally { | |||
| workbook.dispose() | |||
| workbook.close() | |||
| } | |||
| val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12) | |||
| widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | |||
| val out = ByteArrayOutputStream() | |||
| workbook.use { it.write(out) } | |||
| return out.toByteArray() | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,321 @@ | |||
| package com.ffii.fpsms.modules.report.web | |||
| import com.ffii.fpsms.modules.report.service.StockLotOnhandReportService | |||
| import org.apache.poi.ss.usermodel.BorderStyle | |||
| import org.apache.poi.ss.usermodel.CellStyle | |||
| import org.apache.poi.ss.usermodel.DataFormat | |||
| import org.apache.poi.ss.usermodel.FillPatternType | |||
| import org.apache.poi.ss.usermodel.HorizontalAlignment | |||
| import org.apache.poi.ss.usermodel.IndexedColors | |||
| import org.apache.poi.ss.usermodel.Row | |||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||
| import org.apache.poi.ss.usermodel.Workbook | |||
| import org.apache.poi.ss.util.CellRangeAddress | |||
| import org.apache.poi.ss.util.WorkbookUtil | |||
| import org.apache.poi.xssf.streaming.SXSSFWorkbook | |||
| import org.springframework.http.HttpHeaders | |||
| import org.springframework.http.HttpStatus | |||
| import org.springframework.http.MediaType | |||
| import org.springframework.http.ResponseEntity | |||
| import org.springframework.web.bind.annotation.GetMapping | |||
| import org.springframework.web.bind.annotation.RequestMapping | |||
| import org.springframework.web.bind.annotation.RequestParam | |||
| import org.springframework.web.bind.annotation.RestController | |||
| import java.io.ByteArrayOutputStream | |||
| import java.time.LocalTime | |||
| import java.time.format.DateTimeFormatter | |||
| /** | |||
| * 庫存批次現況報告 Stock Balance (Excel only, always today) | |||
| * Excel: /report/print-stock-lot-onhand-excel | |||
| */ | |||
| @RestController | |||
| @RequestMapping("/report") | |||
| class StockLotOnhandReportController( | |||
| private val stockLotOnhandReportService: StockLotOnhandReportService, | |||
| ) { | |||
| private data class ExcelStyles( | |||
| val title: CellStyle, | |||
| val subtitle: CellStyle, | |||
| val header: CellStyle, | |||
| val text: CellStyle, | |||
| val center: CellStyle, | |||
| val number: CellStyle, | |||
| ) | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ | |||
| @GetMapping("/print-stock-lot-onhand-excel") | |||
| fun exportExcel( | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false) storeId: String?, | |||
| @RequestParam(required = false) warehouse: String?, | |||
| @RequestParam(required = false) area: String?, | |||
| @RequestParam(required = false) slot: String?, | |||
| @RequestParam(required = false) lotNo: String?, | |||
| @RequestParam(required = false) stockTakeSectionDescription: String?, | |||
| @RequestParam(required = false) lotOrigin: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val result = stockLotOnhandReportService.search( | |||
| itemCode = itemCode, | |||
| storeId = storeId, | |||
| warehouse = warehouse, | |||
| area = area, | |||
| slot = slot, | |||
| lotNo = lotNo, | |||
| stockTakeSectionDescription = stockTakeSectionDescription, | |||
| lotOrigin = lotOrigin, | |||
| ) | |||
| if (result.rows.isEmpty()) { | |||
| return ResponseEntity<ByteArray>(HttpStatus.NO_CONTENT) | |||
| } | |||
| val excelBytes = createExcel( | |||
| dbData = result.rows, | |||
| reportDate = result.stockDate, | |||
| itemCode = itemCode, | |||
| storeId = storeId, | |||
| warehouse = warehouse, | |||
| area = area, | |||
| slot = slot, | |||
| lotNo = lotNo, | |||
| stockTakeSectionDescription = stockTakeSectionDescription, | |||
| lotOrigin = lotOrigin, | |||
| ) | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.parseMediaType( | |||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |||
| ) | |||
| setContentDispositionFormData("attachment", "StockLotOnhandReport.xlsx") | |||
| set("filename", "StockLotOnhandReport.xlsx") | |||
| } | |||
| return ResponseEntity(excelBytes, headers, HttpStatus.OK) | |||
| } | |||
| private fun createStyles(workbook: Workbook): ExcelStyles { | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| val numberFormat = df.getFormat("#,##0.00;(#,##0.00)") | |||
| fun borders(style: CellStyle) { | |||
| style.borderTop = BorderStyle.THIN | |||
| style.borderBottom = BorderStyle.THIN | |||
| style.borderLeft = BorderStyle.THIN | |||
| style.borderRight = BorderStyle.THIN | |||
| style.verticalAlignment = VerticalAlignment.CENTER | |||
| } | |||
| fun fill(style: CellStyle, color: IndexedColors) { | |||
| style.fillForegroundColor = color.index | |||
| style.fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| } | |||
| val titleStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| val font = workbook.createFont().apply { | |||
| bold = true | |||
| fontHeightInPoints = 16 | |||
| } | |||
| setFont(font) | |||
| } | |||
| val subtitleStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| wrapText = true | |||
| val font = workbook.createFont().apply { | |||
| fontHeightInPoints = 10 | |||
| } | |||
| setFont(font) | |||
| } | |||
| val headerStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| wrapText = true | |||
| borders(this) | |||
| fill(this, IndexedColors.DARK_TEAL) | |||
| val font = workbook.createFont().apply { | |||
| bold = true | |||
| color = IndexedColors.WHITE.index | |||
| fontHeightInPoints = 10 | |||
| } | |||
| setFont(font) | |||
| } | |||
| val textStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| borders(this) | |||
| } | |||
| val centerStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| borders(this) | |||
| } | |||
| val numberStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| borders(this) | |||
| dataFormat = numberFormat | |||
| } | |||
| return ExcelStyles( | |||
| title = titleStyle, | |||
| subtitle = subtitleStyle, | |||
| header = headerStyle, | |||
| text = textStyle, | |||
| center = centerStyle, | |||
| number = numberStyle, | |||
| ) | |||
| } | |||
| private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { | |||
| row.createCell(col).apply { | |||
| setCellValue(value?.toString() ?: "") | |||
| cellStyle = style | |||
| } | |||
| } | |||
| private fun setNumberCell(row: Row, col: Int, value: Any?, numberStyle: CellStyle) { | |||
| val cell = row.createCell(col) | |||
| val n = when (value) { | |||
| null -> null | |||
| is Number -> value.toDouble() | |||
| else -> { | |||
| val raw = value.toString().trim() | |||
| if (raw.isEmpty() || raw == "-") null else raw.replace(",", "").toDoubleOrNull() | |||
| } | |||
| } | |||
| if (n == null) { | |||
| cell.setCellValue("") | |||
| cell.cellStyle = numberStyle | |||
| return | |||
| } | |||
| cell.setCellValue(n) | |||
| cell.cellStyle = numberStyle | |||
| } | |||
| private fun displayFilter(raw: String?): String { | |||
| val v = raw?.trim().orEmpty() | |||
| if (v.isEmpty() || v.equals("All", ignoreCase = true)) return "全部" | |||
| if (v.equals("other", ignoreCase = true)) return "其他" | |||
| return v | |||
| } | |||
| private fun createExcel( | |||
| dbData: List<Map<String, Any>>, | |||
| reportDate: String, | |||
| itemCode: String?, | |||
| storeId: String?, | |||
| warehouse: String?, | |||
| area: String?, | |||
| slot: String?, | |||
| lotNo: String?, | |||
| stockTakeSectionDescription: String?, | |||
| lotOrigin: String?, | |||
| ): ByteArray { | |||
| val workbook = SXSSFWorkbook(100) | |||
| workbook.setCompressTempFiles(true) | |||
| try { | |||
| val styles = createStyles(workbook) | |||
| val reportTitle = "庫存批次現況報告" | |||
| val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) | |||
| val headers = listOf( | |||
| "貨品編號", "貨品名稱", "貨品單位結餘", "單位", | |||
| "批號", "到期日", | |||
| "樓層", "倉庫", "區域", "儲位", | |||
| "最後異動日", "最後異動(入庫/出庫)", | |||
| "批號結餘", "單位均價", "庫存總價值", | |||
| ) | |||
| val totalColumns = headers.size | |||
| var rowIndex = 0 | |||
| val titleRow = sheet.createRow(rowIndex++) | |||
| titleRow.heightInPoints = 24f | |||
| titleRow.createCell(0).apply { | |||
| setCellValue(reportTitle) | |||
| cellStyle = styles.title | |||
| } | |||
| sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) | |||
| val reportDateTime = | |||
| reportDate + | |||
| " (" + | |||
| LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + | |||
| ")" | |||
| val dateRow = sheet.createRow(rowIndex++) | |||
| dateRow.heightInPoints = 18f | |||
| dateRow.createCell(0).apply { | |||
| setCellValue("報告日期(現況):$reportDateTime") | |||
| cellStyle = styles.subtitle | |||
| } | |||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1)) | |||
| val criteriaText = listOf( | |||
| "貨品編號=${displayFilter(itemCode)}", | |||
| "樓層=${displayFilter(storeId)}", | |||
| "倉庫=${displayFilter(warehouse)}", | |||
| "區域=${displayFilter(area)}", | |||
| "儲位=${displayFilter(slot)}", | |||
| "盤點區域說明=${displayFilter(stockTakeSectionDescription)}", | |||
| "批號=${displayFilter(lotNo)}", | |||
| "來源=${displayFilter(lotOrigin)}", | |||
| ).joinToString(" ") | |||
| val criteriaRow = sheet.createRow(rowIndex++) | |||
| criteriaRow.heightInPoints = 32f | |||
| criteriaRow.createCell(0).apply { | |||
| setCellValue("搜尋條件:$criteriaText") | |||
| cellStyle = styles.subtitle | |||
| } | |||
| sheet.addMergedRegion(CellRangeAddress(2, 2, 0, totalColumns - 1)) | |||
| val headerRowIndex = rowIndex | |||
| val headerRow = sheet.createRow(rowIndex++) | |||
| headerRow.heightInPoints = 22f | |||
| headers.forEachIndexed { i, h -> | |||
| headerRow.createCell(i).apply { | |||
| setCellValue(h) | |||
| cellStyle = styles.header | |||
| } | |||
| } | |||
| if (dbData.isEmpty()) { | |||
| val emptyRowIndex = rowIndex | |||
| val r = sheet.createRow(rowIndex++) | |||
| r.heightInPoints = 22f | |||
| r.createCell(0).apply { | |||
| setCellValue("查無資料") | |||
| cellStyle = styles.center | |||
| } | |||
| for (c in 1 until totalColumns) { | |||
| r.createCell(c).cellStyle = styles.center | |||
| } | |||
| sheet.addMergedRegion(CellRangeAddress(emptyRowIndex, emptyRowIndex, 0, totalColumns - 1)) | |||
| } else { | |||
| dbData.forEach { m -> | |||
| val r = sheet.createRow(rowIndex++) | |||
| r.heightInPoints = 18f | |||
| setTextCell(r, 0, m["itemNo"], styles.text) | |||
| setTextCell(r, 1, m["itemName"], styles.text) | |||
| setNumberCell(r, 2, m["totalQtyRaw"], styles.number) | |||
| setTextCell(r, 3, m["unitOfMeasure"], styles.center) | |||
| setTextCell(r, 4, m["lotNo"], styles.text) | |||
| setTextCell(r, 5, m["expiryDate"], styles.center) | |||
| setTextCell(r, 6, m["storeId"], styles.center) | |||
| setTextCell(r, 7, m["warehousePart"], styles.center) | |||
| setTextCell(r, 8, m["areaPart"], styles.center) | |||
| setTextCell(r, 9, m["slotPart"], styles.center) | |||
| setTextCell(r, 10, m["lastTrnDate"], styles.center) | |||
| setTextCell(r, 11, m["lastTrnType"], styles.center) | |||
| setNumberCell(r, 12, m["lotQtyRaw"], styles.number) | |||
| setNumberCell(r, 13, m["avgUnitPriceRaw"], styles.number) | |||
| setNumberCell(r, 14, m["stockValueRaw"], styles.number) | |||
| } | |||
| } | |||
| val lastRowIndex = rowIndex - 1 | |||
| if (lastRowIndex >= headerRowIndex) { | |||
| sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, totalColumns - 1)) | |||
| } | |||
| sheet.createFreezePane(0, headerRowIndex + 1) | |||
| intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) | |||
| .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | |||
| val out = ByteArrayOutputStream() | |||
| workbook.write(out) | |||
| return out.toByteArray() | |||
| } finally { | |||
| workbook.dispose() | |||
| workbook.close() | |||
| } | |||
| } | |||
| } | |||
| @@ -1615,6 +1615,9 @@ open class StockTakeRecordService( | |||
| ) | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 70 | v1.0.0 | 2026-08-31 | |||
| */ | |||
| open fun checkAndUpdateStockTakeStatus(stockTakeId: Long, stockTakeSection: String): Map<String, Any> { | |||
| try { | |||
| val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId) | |||
| @@ -1648,18 +1651,17 @@ open class StockTakeRecordService( | |||
| } | |||
| } | |||
| // 輪次預建後:以本 section 本輪「全部記錄是否已完成第一次盤點」為準(不再與即時庫存行逐行對齊) | |||
| val allLinesHaveRecords = stockTakeRecords.isNotEmpty() && | |||
| stockTakeRecords.all { it.pickerFirstStockTakeQty != null } | |||
| // 以本 section 本輪「列狀態」升單頭。審核員單筆先核(無第一次數量)只要已是 | |||
| // pass/completed 即算完成,不再要求每一筆都有 pickerFirstStockTakeQty。 | |||
| val allRecordsPassed = stockTakeRecords.isNotEmpty() && | |||
| stockTakeRecords.all { it.status == "pass" || it.status == "completed" } | |||
| val allRecordsCompleted = stockTakeRecords.isNotEmpty() && | |||
| stockTakeRecords.all { it.status == "completed" } | |||
| // 6. 如果所有记录都已创建且都是 "pass",更新 stock take 状态为 "approving" | |||
| if (allLinesHaveRecords && allRecordsCompleted) { | |||
| val now = java.time.LocalDateTime.now() | |||
| if (allRecordsCompleted) { | |||
| stockTake.status = StockTakeStatus.COMPLETED | |||
| stockTake.planEnd = java.time.LocalDateTime.now() | |||
| stockTake.planEnd = now | |||
| stockTake.actualEnd = now | |||
| stockTakeRepository.save(stockTake) | |||
| println("Stock take $stockTakeId status updated to COMPLETED - all records are completed") | |||
| return mapOf( | |||
| @@ -1667,10 +1669,9 @@ open class StockTakeRecordService( | |||
| "message" to "Stock take status updated to COMPLETED", | |||
| "updated" to true | |||
| ) | |||
| } else if (allLinesHaveRecords && allRecordsPassed) { | |||
| // 如果所有记录都已创建且都是 "pass" 或 "completed",更新 stock take 状态为 "approving" | |||
| } else if (allRecordsPassed) { | |||
| stockTake.status = StockTakeStatus.APPROVING | |||
| stockTake.actualEnd = java.time.LocalDateTime.now() | |||
| stockTake.actualEnd = now | |||
| stockTakeRepository.save(stockTake) | |||
| println("Stock take $stockTakeId status updated to APPROVING - all records are pass") | |||
| @@ -1684,7 +1685,6 @@ open class StockTakeRecordService( | |||
| "success" to true, | |||
| "message" to "Conditions not met for status update", | |||
| "updated" to false, | |||
| "allLinesHaveRecords" to allLinesHaveRecords, | |||
| "allRecordsPassed" to allRecordsPassed, | |||
| "allRecordsCompleted" to allRecordsCompleted | |||
| ) | |||
| @@ -13,6 +13,8 @@ import com.ffii.fpsms.modules.user.entity.projections.UserCombo; | |||
| public interface UserRepository extends AbstractRepository<User, Long> { | |||
| List<User> findByName(@Param("name") String name); | |||
| List<User> findByNameAndDeletedFalse(String name); | |||
| Optional<User> findByUsernameAndDeletedFalse(String username); | |||
| @@ -20,6 +22,8 @@ public interface UserRepository extends AbstractRepository<User, Long> { | |||
| Optional<User> findByStaffNo(@Param("staffNo") String staffNo); | |||
| Optional<User> findByStaffNoAndDeletedFalse(String staffNo); | |||
| @Modifying | |||
| @Query(value = """ | |||
| INSERT INTO user_authority (userID, authId) | |||
| @@ -92,6 +92,22 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||
| return userRepository.findByUsernameAndDeletedFalse(username); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */ | |||
| public boolean isNameTaken(String name) { | |||
| if (StringUtils.isBlank(name)) { | |||
| return false; | |||
| } | |||
| return !userRepository.findByNameAndDeletedFalse(name).isEmpty(); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */ | |||
| public boolean isStaffNoTaken(String staffNo) { | |||
| if (StringUtils.isBlank(staffNo)) { | |||
| return false; | |||
| } | |||
| return userRepository.findByStaffNoAndDeletedFalse(staffNo).isPresent(); | |||
| } | |||
| // @Transactional(rollbackFor = Exception.class) | |||
| public List<UserRecord> search(SearchUserReq req) { | |||
| StringBuilder sql = new StringBuilder("SELECT" | |||
| @@ -199,11 +215,18 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||
| return instance; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */ | |||
| @Transactional(rollbackFor = Exception.class) | |||
| public User newRecord(NewUserReq req) throws UnsupportedEncodingException { | |||
| if (findByUsername(req.getUsername()).isPresent()) { | |||
| throw new UnprocessableEntityException(ErrorCodes.USERNAME_NOT_AVAILABLE); | |||
| } | |||
| if (isNameTaken(req.getName())) { | |||
| throw new UnprocessableEntityException(ErrorCodes.NAME_NOT_AVAILABLE); | |||
| } | |||
| if (isStaffNoTaken(req.getStaffNo())) { | |||
| throw new UnprocessableEntityException(ErrorCodes.STAFF_NO_NOT_AVAILABLE); | |||
| } | |||
| System.out.println("Start Save"); | |||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.py | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | |||
| import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService | |||
| import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||
| import org.springframework.format.annotation.DateTimeFormat | |||
| @@ -28,6 +29,7 @@ open class PyController( | |||
| private val itemUomService: ItemUomService, | |||
| private val plasticBagPrinterService: PlasticBagPrinterService, | |||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | |||
| private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, | |||
| ) { | |||
| companion object { | |||
| private const val PACKAGING_PROCESS_NAME = "包裝" | |||
| @@ -53,8 +55,22 @@ open class PyController( | |||
| ) | |||
| val ids = orders.mapNotNull { it.id } | |||
| val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | |||
| val printDate = ItemDefaultShelfLifeService.today() | |||
| val shelfLifeByCode = itemDefaultShelfLifeService.printShelfLifeByItemCodes( | |||
| orders.map { it.bom?.item?.code ?: it.bom?.code }, | |||
| ) | |||
| val list = orders.map { jo -> | |||
| PyJobOrderListMapper.toListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) | |||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | |||
| val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(itemCode, shelfLifeByCode, printDate) | |||
| PyJobOrderListMapper.toListItem( | |||
| jo, | |||
| printed[jo.id!!], | |||
| stockInLineRepository, | |||
| itemUomService, | |||
| defaultShelfLifeDays = days, | |||
| useMinus18 = useMinus18, | |||
| expiryDate = expiry, | |||
| ) | |||
| } | |||
| return ResponseEntity.ok(list) | |||
| } | |||
| @@ -1,6 +1,7 @@ | |||
| package com.ffii.fpsms.py | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDate | |||
| import java.time.LocalDateTime | |||
| /** | |||
| @@ -27,4 +28,10 @@ data class PyJobOrderListItem( | |||
| val labelPrintedQty: Long = 0, | |||
| /** Cumulative qty from 激光機 submits (LASER). */ | |||
| val laserPrintedQty: Long = 0, | |||
| /** Effective shelf life days used for print (chilled or -18, according to useMinus18). */ | |||
| val defaultShelfLifeDays: Int? = null, | |||
| /** True when expiry is computed from minus18Days instead of chilled defaultDays. */ | |||
| val useMinus18: Boolean? = null, | |||
| /** Print date (today, Asia/Hong_Kong) + effective shelf life days. */ | |||
| val expiryDate: LocalDate? = null, | |||
| ) | |||
| @@ -1,8 +1,11 @@ | |||
| package com.ffii.fpsms.py | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrder | |||
| import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService | |||
| import com.ffii.fpsms.modules.master.service.ItemPrintShelfLife | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||
| import java.time.LocalDate | |||
| object PyJobOrderListMapper { | |||
| @@ -80,6 +83,9 @@ object PyJobOrderListMapper { | |||
| printed: PrintedQtyByChannel?, | |||
| stockInLineRepository: StockInLineRepository, | |||
| itemUomService: ItemUomService, | |||
| defaultShelfLifeDays: Int? = null, | |||
| useMinus18: Boolean? = null, | |||
| expiryDate: LocalDate? = null, | |||
| ): PyJobOrderListItem { | |||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | |||
| val baseName = jo.bom?.name ?: jo.bom?.item?.name | |||
| @@ -107,6 +113,9 @@ object PyJobOrderListMapper { | |||
| bagPrintedQty = p.bagPrintedQty, | |||
| labelPrintedQty = p.labelPrintedQty, | |||
| laserPrintedQty = p.laserPrintedQty, | |||
| defaultShelfLifeDays = defaultShelfLifeDays, | |||
| useMinus18 = useMinus18, | |||
| expiryDate = expiryDate, | |||
| ) | |||
| } | |||
| @@ -116,6 +125,9 @@ object PyJobOrderListMapper { | |||
| printed: PrintedQtyByChannel?, | |||
| stockInLineRepository: StockInLineRepository, | |||
| itemUomService: ItemUomService, | |||
| defaultShelfLifeDays: Int? = null, | |||
| useMinus18: Boolean? = null, | |||
| expiryDate: LocalDate? = null, | |||
| ): PyJobOrderListItem { | |||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | |||
| val baseName = jo.bom?.name ?: jo.bom?.item?.name | |||
| @@ -143,6 +155,20 @@ object PyJobOrderListMapper { | |||
| bagPrintedQty = p.bagPrintedQty, | |||
| labelPrintedQty = p.labelPrintedQty, | |||
| laserPrintedQty = p.laserPrintedQty, | |||
| defaultShelfLifeDays = defaultShelfLifeDays, | |||
| useMinus18 = useMinus18, | |||
| expiryDate = expiryDate, | |||
| ) | |||
| } | |||
| fun shelfLifeForItem( | |||
| itemCode: String?, | |||
| byCode: Map<String, ItemPrintShelfLife>, | |||
| printDate: LocalDate = ItemDefaultShelfLifeService.today(), | |||
| ): Triple<Int?, Boolean?, LocalDate?> { | |||
| val info = byCode[itemCode?.trim()?.uppercase().orEmpty()] | |||
| val days = info?.effectiveDays | |||
| val expiry = days?.let { ItemDefaultShelfLifeService.expiryOn(printDate, it) } | |||
| return Triple(days, info?.useMinus18, expiry) | |||
| } | |||
| } | |||
| @@ -0,0 +1,8 @@ | |||
| --liquibase formatted sql | |||
| --changeset fpsms:add_m18_sync_authority | |||
| --preconditions onFail:MARK_RAN | |||
| --precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'M18_SYNC' | |||
| --comment: Manual M18 sync page (/m18Syn): ADMIN or M18_SYNC | |||
| INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) | |||
| VALUES ('M18_SYNC', 'M18同步', 'M18', 'Allow manual M18 sync by document or item code'); | |||
| @@ -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,18 @@ | |||
| --liquibase formatted sql | |||
| --changeset fpsms:create_onpack_expiry_item_code | |||
| --comment: UI-managed item codes for 汁水機 OnPack expiry ZIP (dynamic PP1181 template) | |||
| CREATE TABLE `onpack_expiry_item_code` | |||
| ( | |||
| `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 DEFAULT 'juice' COMMENT 'juice=汁水機 expiry ZIP', | |||
| `itemCode` VARCHAR(50) NOT NULL, | |||
| CONSTRAINT pk_onpack_expiry_item_code PRIMARY KEY (`id`), | |||
| UNIQUE KEY uk_onpack_expiry_item_code (`machine`, `itemCode`) | |||
| ); | |||
| @@ -0,0 +1,44 @@ | |||
| --liquibase formatted sql | |||
| --changeset fpsms:seed_onpack_expiry_item_code_pp | |||
| --comment: Seed juice expiry ZIP list from onpack2030 folder PP*.image | |||
| INSERT INTO `onpack_expiry_item_code` | |||
| (`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, `machine`, `itemCode`) | |||
| VALUES | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1041'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1074'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1078'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1080'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1082'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1088'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1117'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1118'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1126'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1136'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1137'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1144'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1148'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1152'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1156'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1178'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1180'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1181'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1185'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1209'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1213'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1214'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1216'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1217'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1234'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2211'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2214'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2215'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2243'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2248'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2250'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2262'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2282'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2317'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2331'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2341'), | |||
| (NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2404'); | |||
| @@ -0,0 +1,6 @@ | |||
| --liquibase formatted sql | |||
| --changeset fpsms:onpack_expiry_item_code_print_name | |||
| --comment: Editable OnPack Product BMP name (Chinese name + unit); blank uses items + stock UOM | |||
| ALTER TABLE `onpack_expiry_item_code` | |||
| ADD COLUMN `printName` VARCHAR(255) NULL COMMENT 'OnPack Product line; overrides items name + unit' AFTER `itemCode`; | |||
| @@ -79,7 +79,7 @@ | |||
| <band height="18"> | |||
| <textField textAdjust="StretchHeight"> | |||
| <reportElement isPrintRepeatedValues="false" x="11" y="0" width="579" height="18" uuid="5b2d2e95-26eb-4e8c-93ba-99eaed3490df"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Top" markup="styled"> | |||
| <textElement textAlignment="Left" verticalAlignment="Top" markup="none"> | |||
| <font fontName="微軟正黑體" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{itemNo}+" "+$F{itemName}+" "+$F{unitOfMeasure}]]></textFieldExpression> | |||
| @@ -0,0 +1,66 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.service | |||
| import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest | |||
| import org.junit.jupiter.api.Assertions.assertEquals | |||
| import org.junit.jupiter.api.Assertions.assertNull | |||
| 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 laserAckLooksInvalid_detects_plugin_reject() { | |||
| assertEquals(false, PlasticBagPrinterService.laserAckLooksInvalid(null)) | |||
| assertEquals(false, PlasticBagPrinterService.laserAckLooksInvalid("receive;;")) | |||
| assertEquals(true, PlasticBagPrinterService.laserAckLooksInvalid("errorinvalid data")) | |||
| assertEquals(true, PlasticBagPrinterService.laserAckLooksInvalid("INVALID")) | |||
| } | |||
| @Test | |||
| fun sendRequest_accepts_expiry_as_iso_or_jackson_array() { | |||
| val mapper = jacksonObjectMapper() | |||
| val fromIso = mapper.readValue( | |||
| """{"expiryDate":"2026-08-27","source":"MANUAL"}""", | |||
| LaserBag2SendRequest::class.java, | |||
| ) | |||
| assertEquals("2026-08-27", fromIso.expiryDate) | |||
| val fromArray = mapper.readValue( | |||
| """{"expiryDate":[2026,8,27],"source":"MANUAL"}""", | |||
| LaserBag2SendRequest::class.java, | |||
| ) | |||
| assertEquals("2026-08-27", fromArray.expiryDate) | |||
| val missing = mapper.readValue("{}", LaserBag2SendRequest::class.java) | |||
| assertNull(missing.expiryDate) | |||
| } | |||
| @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,42 @@ | |||
| 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 | |||
| import org.springframework.core.io.ClassPathResource | |||
| class OnPackPp1181MasterTest { | |||
| @Test | |||
| fun rewriteImageXml_swaps_pp1181_filenames() { | |||
| val xml = """<FileName>pp1181Product.bmp</FileName><FileName>pp1181qr.bmp</FileName>""" | |||
| val out = OnPackPp1181Master.rewriteImageXml(xml, "PP1041") | |||
| assertTrue(out.contains("pp1041Product.bmp")) | |||
| assertTrue(out.contains("pp1041qr.bmp")) | |||
| assertFalse(out.contains("pp1181")) | |||
| } | |||
| @Test | |||
| fun rewriteJobXml_points_at_cloned_image() { | |||
| val out = OnPackPp1181Master.rewriteJobXml( | |||
| "<Job><ImageFileName>PP1181.image</ImageFileName></Job>", | |||
| "pp1041.image", | |||
| ) | |||
| assertEquals("<Job><ImageFileName>pp1041.image</ImageFileName></Job>", out) | |||
| } | |||
| @Test | |||
| fun classpath_exp_master_rewrites_all_five_slots() { | |||
| val resource = ClassPathResource(OnPackPp1181Master.MASTER_IMAGE) | |||
| assertTrue(resource.exists(), "missing ${OnPackPp1181Master.MASTER_IMAGE}") | |||
| val bytes = OnPackPp1181Master.rewriteImageBytes(resource.inputStream.use { it.readBytes() }, "PP2404") | |||
| val (xml, _) = OnPackImageTemplateCodec.decode(bytes) | |||
| assertTrue(xml.contains("pp2404Product.bmp")) | |||
| assertTrue(xml.contains("pp2404Code.bmp")) | |||
| assertTrue(xml.contains("pp2404Date.bmp")) | |||
| assertTrue(xml.contains("pp2404qr.bmp")) | |||
| assertTrue(xml.contains("pp2404exp.bmp")) | |||
| assertFalse(xml.contains("pp1181", ignoreCase = true)) | |||
| } | |||
| } | |||
| @@ -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,67 @@ | |||
| package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRequest | |||
| import org.junit.jupiter.api.Assertions.assertEquals | |||
| import org.junit.jupiter.api.Assertions.assertThrows | |||
| import org.junit.jupiter.api.Test | |||
| import org.springframework.web.server.ResponseStatusException | |||
| 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)), | |||
| ) | |||
| } | |||
| @Test | |||
| fun normalizeItemCode_trims_and_uppercases() { | |||
| assertEquals("F0013", ItemDefaultShelfLifeService.normalizeItemCode(" f0013 ")) | |||
| assertEquals("", ItemDefaultShelfLifeService.normalizeItemCode(" ")) | |||
| } | |||
| @Test | |||
| fun validateRequest_rejects_blank_code_and_negative_days() { | |||
| assertThrows(ResponseStatusException::class.java) { | |||
| ItemDefaultShelfLifeService.validateRequest(ItemDefaultShelfLifeRequest(), "") | |||
| } | |||
| assertThrows(ResponseStatusException::class.java) { | |||
| ItemDefaultShelfLifeService.validateRequest( | |||
| ItemDefaultShelfLifeRequest(itemCode = "F0013", defaultDays = -1), | |||
| "F0013", | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -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.assertTrue | |||
| import org.junit.jupiter.api.Test | |||
| import java.time.LocalDate | |||
| import com.ffii.fpsms.modules.master.service.ItemPrintShelfLife | |||
| class PyJobOrderListMapperTest { | |||
| @@ -64,14 +66,38 @@ class PyJobOrderListMapperTest { | |||
| } | |||
| @Test | |||
| fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() { | |||
| assertEquals( | |||
| "鮮檸檬汁(P+4)", | |||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"), | |||
| ) | |||
| assertEquals( | |||
| "咖哩汁(1包X2磅)", | |||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"), | |||
| ) | |||
| } | |||
| fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() { | |||
| assertEquals( | |||
| "鮮檸檬汁(P+4)", | |||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"), | |||
| ) | |||
| assertEquals( | |||
| "咖哩汁(1包X2磅)", | |||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"), | |||
| ) | |||
| } | |||
| @Test | |||
| fun shelfLifeForItem_computes_expiry_from_print_date() { | |||
| val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem( | |||
| "f0025", | |||
| mapOf("F0025" to ItemPrintShelfLife(365, false)), | |||
| LocalDate.of(2026, 8, 20), | |||
| ) | |||
| assertEquals(365, days) | |||
| assertEquals(false, useMinus18) | |||
| assertEquals(LocalDate.of(2027, 8, 20), expiry) | |||
| } | |||
| @Test | |||
| fun shelfLifeForItem_is_null_when_item_missing() { | |||
| val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem( | |||
| "PP1175", | |||
| emptyMap(), | |||
| LocalDate.of(2026, 8, 20), | |||
| ) | |||
| assertEquals(null, days) | |||
| assertEquals(null, useMinus18) | |||
| assertEquals(null, expiry) | |||
| } | |||
| } | |||