# Conflicts: # src/main/java/com/ffii/fpsms/modules/master/service/BomService.ktbomUpdateTest
| @@ -93,6 +93,10 @@ kotlin { | |||||
| jvmToolchain(17) | jvmToolchain(17) | ||||
| } | } | ||||
| test { | |||||
| useJUnitPlatform() | |||||
| } | |||||
| bootRun { | bootRun { | ||||
| // Use db-local profile by default so datasource is loaded (application-db-local.yml). | // Use db-local profile by default so datasource is loaded (application-db-local.yml). | ||||
| // Override with: ./gradlew bootRun --args='--spring.profiles.active=prod' | // Override with: ./gradlew bootRun --args='--spring.profiles.active=prod' | ||||
| @@ -0,0 +1,85 @@ | |||||
| # Deploy note — PO line m18Id rematch (recode) | |||||
| Date: 2026-08-19 | |||||
| Branch / build: uncommitted (QA before prod) | |||||
| Author: agent + QA | |||||
| ## Summary | |||||
| - PO/DO sync failed when M18 recoded a product (`proId` changed, item **code** unchanged), e.g. `PFP002PO26080303` lines `20022` / `20023` / `20024`. | |||||
| - Sync now **links** the new M18 id onto the existing local item (same **code + type**) and **does not** rebuild/delete `item_uom`. | |||||
| ## Scope | |||||
| - Backend: `ItemsService.saveItem`, `M18MasterDataService.saveProduct` / `saveProducts`, remap helper | |||||
| - Frontend: none | |||||
| - DB / Liquibase: none | |||||
| - Config / ops: none (use existing `GET /m18/test/po-by-code`) | |||||
| ## Commits | |||||
| - (not committed yet) | |||||
| ## Safety rules (what this deploy will / will not do) | |||||
| | Situation | Behaviour | | |||||
| |-----------|-----------| | |||||
| | Local item already has that `m18Id` | Unchanged: normal product/PO update | | |||||
| | Same **code + type**, new unused `m18Id` | Update `items.m18Id` only. **No** UOM delete/rebuild, **no** QC/name wipe | | |||||
| | New `m18Id` already on **another** item | Refuse (keep old mapping) | | |||||
| | Same code, **different** type | Will **not** auto-link (same as before for type mismatch) | | |||||
| | Brand-new M18 product, no local code | Creates item + UOMs as before | | |||||
| | Scheduled product sync of already-mapped items | Unchanged full product/UOM sync | | |||||
| | Local PO status not `PENDING` | PO resync is **skipped** (existing rule) | | |||||
| ## Pre-check SQL (prod or staging, before resync) | |||||
| ```sql | |||||
| -- 1) Failed PO lines for this PO | |||||
| SELECT d.id, d.m18Id AS m18_line_id, d.status, d.dataLog, d.createDate | |||||
| FROM m18_data_log d | |||||
| WHERE d.refType = 'Purchase Order Line' | |||||
| AND d.status = 'FAIL' | |||||
| AND d.dataLog LIKE '%PFP002PO26080303%' | |||||
| ORDER BY d.id DESC | |||||
| LIMIT 20; | |||||
| -- 2) Local PO must be PENDING | |||||
| SELECT id, code, status, m18Id | |||||
| FROM purchase_order | |||||
| WHERE code = 'PFP002PO26080303' AND deleted = 0; | |||||
| -- 3) After you know the 3 M18 product codes, confirm one local row each | |||||
| SELECT id, code, name, type, m18Id | |||||
| FROM items | |||||
| WHERE deleted = 0 AND code IN ('CODE1', 'CODE2', 'CODE3'); | |||||
| ``` | |||||
| Confirm each of the three codes has **exactly one** local row, and its current `m18Id` is **not** already `20022`/`20023`/`20024` on a *different* item: | |||||
| ```sql | |||||
| SELECT id, code, m18Id FROM items | |||||
| WHERE deleted = 0 AND m18Id IN (20022, 20023, 20024); | |||||
| ``` | |||||
| Expected before fix: **no rows** (or only unrelated items — if any, **do not** auto-resync; fix mapping manually). | |||||
| ## Test plan | |||||
| | # | Steps (who / where / data) | Expected result | | |||||
| |---|----|-----| | |||||
| | 1 | **Staging first.** Snapshot `items.m18Id` + `item_uom` counts for the 3 item codes. | Baseline recorded | | |||||
| | 2 | Confirm M18 products `20022`/`20023`/`20024` still have the **same codes** as local items. | Codes match; types still map to the same FPSMS `type` | | |||||
| | 3 | Confirm local PO `PFP002PO26080303` status is **PENDING**. | If not pending, stop — resync will skip the whole PO | | |||||
| | 4 | Call `GET /m18/test/po-by-code?code=PFP002PO26080303` (auth as ops). | HTTP 200; `totalFail` for those 3 lines is 0 (or success list includes the PO) | | |||||
| | 5 | SQL: `items.m18Id` for the 3 codes is now 20022/20023/20024. `item_uom` **row count and ids unchanged**. QC category / shelf life unchanged. | Remap-only; no UOM wipe | | |||||
| | 6 | UI: PO workbench / PO detail for `PFP002PO26080303` shows **6 lines** (previously 3 missing). | Failed lines created | | |||||
| | 7 | **Regression:** pick a normal mapped item, run scheduled or `GET /m18/product/{existingM18Id}`. | Item still updates as before; UOMs still sync | | |||||
| | 8 | Confirm no new `PO_LINE FAIL` email for this PO after resync. | Alert not re-sent for these 3 proIds | | |||||
| ## Out of scope / not tested | |||||
| - Recoded products whose **item code also changed** (will not auto-link) | |||||
| - Non-`PENDING` PO overwrite (by design skipped) | |||||
| - DO with the same recode (same `resolveLocalItemId` path; spot-check only if a DO uses these 3 items) | |||||
| - Live M18 UOM id change on the recoded product (PO qty conversion still uses existing purchase UOM) | |||||
| ## Rollback | |||||
| - Revert backend deploy (no Liquibase, no frontend). | |||||
| - If only `items.m18Id` was updated, restore previous `m18Id` from the pre-check snapshot. PO lines already inserted stay; delete/fix only if QA rejects the PO. | |||||
| - Do **not** run a full product resync on the new proIds expecting to undo UOMs — this path does not rewrite UOMs. | |||||
| @@ -0,0 +1,38 @@ | |||||
| # -*- mode: python ; coding: utf-8 -*- | |||||
| a = Analysis( | |||||
| ['Bag4.py'], | |||||
| pathex=[], | |||||
| binaries=[], | |||||
| datas=[], | |||||
| hiddenimports=[], | |||||
| hookspath=[], | |||||
| hooksconfig={}, | |||||
| runtime_hooks=[], | |||||
| excludes=[], | |||||
| noarchive=False, | |||||
| optimize=0, | |||||
| ) | |||||
| pyz = PYZ(a.pure) | |||||
| exe = EXE( | |||||
| pyz, | |||||
| a.scripts, | |||||
| a.binaries, | |||||
| a.datas, | |||||
| [], | |||||
| name='Bag4', | |||||
| debug=False, | |||||
| bootloader_ignore_signals=False, | |||||
| strip=False, | |||||
| upx=False, | |||||
| upx_exclude=[], | |||||
| runtime_tmpdir=None, | |||||
| console=False, | |||||
| disable_windowed_traceback=False, | |||||
| argv_emulation=False, | |||||
| target_arch=None, | |||||
| codesign_identity=None, | |||||
| entitlements_file=None, | |||||
| ) | |||||
| @@ -1,24 +1,30 @@ | |||||
| # Bag3 Windows exe build (run all commands in this python/ folder) | |||||
| # Bag3 / Bag4 Windows exe build (run all commands in this python/ folder) | |||||
| py -m pip install --upgrade pyinstaller | py -m pip install --upgrade pyinstaller | ||||
| py -m pip install --upgrade pywin32 | py -m pip install --upgrade pywin32 | ||||
| py -m pip install --upgrade Pillow "qrcode[pil]" requests | |||||
| py -m pip install --upgrade Pillow "qrcode[pil]" requests pyserial | |||||
| py -m PyInstaller --noconfirm --clean Bag3.spec | |||||
| # --- Bag4 (DataFlex with expiry) --- | |||||
| py -m PyInstaller --noconfirm --clean Bag4.spec | |||||
| # Output: dist\Bag4.exe (one-file). Copy Bag4.exe to the client PC. | |||||
| # Settings are created next to the exe: bag4_settings.json | |||||
| # Do not overwrite Bag3.exe; Bag3 and Bag4 are separate. | |||||
| # Output: dist\Bag3\Bag3.exe plus dist\Bag3\_internal\... | |||||
| # Copy the ENTIRE dist\Bag3\ folder to the client PC (not only Bag3.exe). | |||||
| # --- Bag3 (DataFlex without expiry) --- | |||||
| py -m PyInstaller --noconfirm --clean Bag3.spec | |||||
| # Output: dist\Bag3.exe | |||||
| # --- If the client exe flashes and closes --- | # --- If the client exe flashes and closes --- | ||||
| 1) On the client PC, open cmd in the Bag3 folder and run: | |||||
| Bag3.exe | |||||
| You should see the error in the console, or open bag3_startup_error.log next to Bag3.exe. | |||||
| 1) On the client PC, open cmd in the folder that contains the exe and run: | |||||
| Bag4.exe | |||||
| You should see the error in the console, or open bag4_startup_error.log next to Bag4.exe. | |||||
| (Bag3: bag3_startup_error.log) | |||||
| 2) Compare BUILD machines (both should match): | 2) Compare BUILD machines (both should match): | ||||
| py --version | py --version | ||||
| py -m PyInstaller --version | py -m PyInstaller --version | ||||
| py -m pip show pyinstaller pywin32 Pillow qrcode requests | |||||
| py -m pip show pyinstaller pywin32 Pillow qrcode requests pyserial | |||||
| A broken build is often caused by: | A broken build is often caused by: | ||||
| - Different Python major version (e.g. 3.13 vs 3.11) | - Different Python major version (e.g. 3.13 vs 3.11) | ||||
| @@ -28,8 +34,8 @@ py -m PyInstaller --noconfirm --clean Bag3.spec | |||||
| 3) Rebuild on the machine that works, or reinstall Python from python.org (64-bit) | 3) Rebuild on the machine that works, or reinstall Python from python.org (64-bit) | ||||
| and reinstall deps above, then rebuild. | and reinstall deps above, then rebuild. | ||||
| 4) Bag3.spec disables UPX (upx=False) for stability; do not re-enable unless you test on the client. | |||||
| 4) Spec files disable UPX (upx=False) for stability; do not re-enable unless you test on the client. | |||||
| 5) Client needs 64-bit Windows and Microsoft VC++ Redistributable (same as your Python installer). | 5) Client needs 64-bit Windows and Microsoft VC++ Redistributable (same as your Python installer). | ||||
| 6) Antivirus may quarantine files under _internal\ — whitelist the Bag3 folder if the log mentions missing DLL. | |||||
| 6) Antivirus may quarantine the exe — whitelist the folder if the log mentions missing DLL. | |||||
| @@ -0,0 +1,186 @@ | |||||
| #!/usr/bin/env python3 | |||||
| """Generate Liquibase seed SQL from 工埸產品保質期 Excel (joExpiry.xlsx). | |||||
| Stores both chilled defaultDays and minus18Days. | |||||
| useMinus18 = 1 only when the row has no non-18 shelf life (frozen-only). | |||||
| Flip useMinus18 in DB later for items that should print -18 days. | |||||
| Example: | |||||
| py scripts/generate_item_default_shelf_life_liquibase.py | |||||
| py scripts/generate_item_default_shelf_life_liquibase.py "C:\\Users\\Administrator\\Downloads\\joExpiry.xlsx" | |||||
| """ | |||||
| from __future__ import annotations | |||||
| import argparse | |||||
| import datetime as dt | |||||
| from pathlib import Path | |||||
| import openpyxl | |||||
| PREFERRED_SHEET = "XXXXXXXX" | |||||
| OUT_SQL = Path( | |||||
| "src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/" | |||||
| "05_seed_item_default_shelf_life_minus18_flag.sql" | |||||
| ) | |||||
| def sql_str(value: str | None) -> str: | |||||
| if value is None: | |||||
| return "NULL" | |||||
| return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'" | |||||
| def sql_int(value: int | None) -> str: | |||||
| return "NULL" if value is None else str(value) | |||||
| def norm_temp(raw) -> str | None: | |||||
| if raw is None: | |||||
| return None | |||||
| s = str(raw).strip().replace("℃", "").replace("°C", "").replace(" ", "") | |||||
| if not s or s.lower() in ("n/a", "none", "null", "-", "nan"): | |||||
| return None | |||||
| return s | |||||
| def is_minus18(temp: str | None) -> bool: | |||||
| return temp in ("-18", "-18.0") | |||||
| def to_days(raw) -> int | None: | |||||
| if raw is None or raw == "": | |||||
| return None | |||||
| if isinstance(raw, str) and raw.strip().lower() in ("n/a", "-", "none", "null"): | |||||
| return None | |||||
| try: | |||||
| n = int(round(float(raw))) | |||||
| except (TypeError, ValueError): | |||||
| return None | |||||
| return n if n > 0 else None | |||||
| def pick_days(t1, d1, t2, d2) -> tuple[int | None, str | None, int | None]: | |||||
| """Return (chilledDays, chilledTemp, minus18Days).""" | |||||
| chilled_days = None | |||||
| chilled_temp = None | |||||
| minus18_days = None | |||||
| for temp, days in ((norm_temp(t1), to_days(d1)), (norm_temp(t2), to_days(d2))): | |||||
| if not temp or not days: | |||||
| continue | |||||
| if is_minus18(temp): | |||||
| if minus18_days is None: | |||||
| minus18_days = days | |||||
| elif chilled_days is None: | |||||
| chilled_days = days | |||||
| chilled_temp = temp | |||||
| return chilled_days, chilled_temp, minus18_days | |||||
| def load_rows(xlsx: Path) -> tuple[str, list[dict]]: | |||||
| wb = openpyxl.load_workbook(xlsx, data_only=True) | |||||
| sheet_name = PREFERRED_SHEET if PREFERRED_SHEET in wb.sheetnames else wb.sheetnames[0] | |||||
| ws = wb[sheet_name] | |||||
| kept: list[dict] = [] | |||||
| seen: set[str] = set() | |||||
| for row in ws.iter_rows(min_row=4, values_only=True): | |||||
| code_raw = row[3] | |||||
| if not code_raw: | |||||
| continue | |||||
| code = str(code_raw).strip().upper() | |||||
| if not code or code in seen: | |||||
| continue | |||||
| chilled, storage, minus18 = pick_days(row[5], row[6], row[7], row[8]) | |||||
| if chilled is None and minus18 is None: | |||||
| continue | |||||
| seen.add(code) | |||||
| use_minus18 = 1 if chilled is None else 0 | |||||
| name = str(row[4]).strip() if row[4] else "" | |||||
| cat = str(row[2]).strip() if row[2] else "" | |||||
| remark = f"{cat} {name}".strip()[:255] | |||||
| kept.append( | |||||
| { | |||||
| "itemCode": code, | |||||
| "defaultDays": chilled, | |||||
| "minus18Days": minus18, | |||||
| "useMinus18": use_minus18, | |||||
| "storageC": "-18" if use_minus18 else storage, | |||||
| "remarks": remark, | |||||
| } | |||||
| ) | |||||
| return sheet_name, kept | |||||
| def render_sql(sheet_name: str, kept: list[dict], source_name: str) -> str: | |||||
| today = dt.date.today().isoformat() | |||||
| n_flag = sum(1 for r in kept if r["useMinus18"] == 1) | |||||
| lines = [ | |||||
| "--liquibase formatted sql", | |||||
| "", | |||||
| "--changeset fpsms:seed_item_default_shelf_life_minus18_flag", | |||||
| f"--comment: Upsert joExpiry.xlsx sheet {sheet_name}: defaultDays + minus18Days + useMinus18 flag", | |||||
| f"-- generated {today}; rows={len(kept)} useMinus18=1 (frozen-only default)={n_flag}", | |||||
| "INSERT INTO `item_default_shelf_life`", | |||||
| "(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`,", | |||||
| " `itemCode`, `defaultDays`, `minus18Days`, `useMinus18`, `openedDays`, `storageC`, `remarks`)", | |||||
| "VALUES", | |||||
| ] | |||||
| value_rows = [] | |||||
| for r in kept: | |||||
| value_rows.append( | |||||
| "(" | |||||
| + ", ".join( | |||||
| [ | |||||
| "NOW()", | |||||
| "'system'", | |||||
| "0", | |||||
| "NOW()", | |||||
| "'system'", | |||||
| "0", | |||||
| sql_str(r["itemCode"]), | |||||
| sql_int(r["defaultDays"]), | |||||
| sql_int(r["minus18Days"]), | |||||
| str(r["useMinus18"]), | |||||
| "NULL", | |||||
| sql_str(r["storageC"]), | |||||
| sql_str(r["remarks"]), | |||||
| ] | |||||
| ) | |||||
| + ")" | |||||
| ) | |||||
| lines.append(",\n".join(value_rows)) | |||||
| lines.append("ON DUPLICATE KEY UPDATE") | |||||
| lines.append(" `defaultDays` = VALUES(`defaultDays`),") | |||||
| lines.append(" `minus18Days` = VALUES(`minus18Days`),") | |||||
| lines.append(" `useMinus18` = VALUES(`useMinus18`),") | |||||
| lines.append(" `openedDays` = VALUES(`openedDays`),") | |||||
| lines.append(" `storageC` = VALUES(`storageC`),") | |||||
| lines.append(" `remarks` = VALUES(`remarks`),") | |||||
| lines.append(" `modified` = NOW(),") | |||||
| lines.append(" `modifiedBy` = 'system',") | |||||
| lines.append(" `deleted` = 0;") | |||||
| lines.append("") | |||||
| return "\n".join(lines) | |||||
| def main() -> None: | |||||
| parser = argparse.ArgumentParser() | |||||
| parser.add_argument( | |||||
| "xlsx", | |||||
| nargs="?", | |||||
| default=str(Path.home() / "Downloads" / "joExpiry.xlsx"), | |||||
| ) | |||||
| args = parser.parse_args() | |||||
| xlsx = Path(args.xlsx) | |||||
| if not xlsx.is_file(): | |||||
| raise SystemExit(f"Excel not found: {xlsx}") | |||||
| sheet_name, kept = load_rows(xlsx) | |||||
| sql = render_sql(sheet_name, kept, xlsx.name) | |||||
| OUT_SQL.parent.mkdir(parents=True, exist_ok=True) | |||||
| OUT_SQL.write_text(sql, encoding="utf-8") | |||||
| n_flag = sum(1 for r in kept if r["useMinus18"] == 1) | |||||
| print(f"sheet={sheet_name} rows={len(kept)} useMinus18=1={n_flag} useMinus18=0={len(kept) - n_flag}") | |||||
| print(f"wrote {OUT_SQL}") | |||||
| if __name__ == "__main__": | |||||
| main() | |||||
| @@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer { | |||||
| registry.addMapping("/**") | registry.addMapping("/**") | ||||
| .allowedHeaders("*") | .allowedHeaders("*") | ||||
| .allowedOrigins("*") | .allowedOrigins("*") | ||||
| .exposedHeaders("filename", "Content-Disposition") | |||||
| .exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry") | |||||
| .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"); | .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. 51 | v1.0.1 | 2026-08-06 | ||||
| * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 | * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 | ||||
| * (stockAdjustment/submit → INVENTORY_ADJUST only) | * (stockAdjustment/submit → INVENTORY_ADJUST only) | ||||
| @@ -128,6 +129,15 @@ public class SecurityConfig { | |||||
| /* 工序「已完成」(Just Pass):僅 ADMIN */ | /* 工序「已完成」(Just Pass):僅 ADMIN */ | ||||
| .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**") | .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**") | ||||
| .hasAuthority("ADMIN") | .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()) | .anyRequest().authenticated()) | ||||
| .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | ||||
| (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | ||||
| @@ -195,6 +195,17 @@ open class M18MasterDataService( | |||||
| return itemsService.findByM18Id(m18ItemId)?.id | return itemsService.findByM18Id(m18ItemId)?.id | ||||
| } | } | ||||
| private fun mapM18ProductType(udfProducttype: String?): String { | |||||
| return when (udfProducttype) { | |||||
| M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type | |||||
| M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type | |||||
| M18ItemType.FG.type -> ItemType.FG.type | |||||
| M18ItemType.SFG.type -> ItemType.SFG.type | |||||
| M18ItemType.ITEM.type -> ItemType.ITEM.type | |||||
| else -> ItemType.MATERIAL.type | |||||
| } | |||||
| } | |||||
| open fun saveProduct(id: Long): MessageResponse? { | open fun saveProduct(id: Long): MessageResponse? { | ||||
| try { | try { | ||||
| ensureCunitSeededForAllIfEmpty() | ensureCunitSeededForAllIfEmpty() | ||||
| @@ -203,20 +214,14 @@ open class M18MasterDataService( | |||||
| val price = itemDetail?.data?.price | val price = itemDetail?.data?.price | ||||
| if (itemDetail != null && pro != null) { | if (itemDetail != null && pro != null) { | ||||
| val mappedType = mapM18ProductType(pro.udfProducttype) | |||||
| val existingItem = itemsService.findByM18Id(id) | val existingItem = itemsService.findByM18Id(id) | ||||
| val saveItemRequest = NewItemRequest( | val saveItemRequest = NewItemRequest( | ||||
| code = pro.code, | code = pro.code, | ||||
| name = pro.desc, | name = pro.desc, | ||||
| // type = if (pro.seriesId == m18Config.SERIESID_PF) ProductType.MATERIAL | // type = if (pro.seriesId == m18Config.SERIESID_PF) ProductType.MATERIAL | ||||
| // else ItemType.PRODUCT, | // else ItemType.PRODUCT, | ||||
| type = when (pro.udfProducttype) { | |||||
| M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type | |||||
| M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type | |||||
| M18ItemType.FG.type -> ItemType.FG.type | |||||
| M18ItemType.SFG.type -> ItemType.SFG.type | |||||
| M18ItemType.ITEM.type -> ItemType.ITEM.type | |||||
| else -> ItemType.MATERIAL.type | |||||
| }, | |||||
| type = mappedType, | |||||
| id = existingItem?.id, | id = existingItem?.id, | ||||
| description = pro.desc, | description = pro.desc, | ||||
| remarks = null, | remarks = null, | ||||
| @@ -247,6 +252,12 @@ open class M18MasterDataService( | |||||
| logger.error("saveItem duplicate code for M18 item $id (code=${pro.code}): ${savedItem.message}") | logger.error("saveItem duplicate code for M18 item $id (code=${pro.code}): ${savedItem.message}") | ||||
| return null | return null | ||||
| } | } | ||||
| if (ItemM18IdRemapSupport.isM18IdLinkOnly(existingItem == null, savedItem.message)) { | |||||
| logger.warn( | |||||
| "Linked M18 product id=$id code=${pro.code} to existing local item id=$localItemId; skip UOM rebuild" | |||||
| ) | |||||
| return savedItem.copy(id = localItemId) | |||||
| } | |||||
| logger.info("Processing item uom...") | logger.info("Processing item uom...") | ||||
| // Find the item uom that ready to delete (not in m18) | // Find the item uom that ready to delete (not in m18) | ||||
| val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | ||||
| @@ -382,6 +393,7 @@ open class M18MasterDataService( | |||||
| val price = itemDetail?.data?.price | val price = itemDetail?.data?.price | ||||
| if (itemDetail != null && pro != null) { | if (itemDetail != null && pro != null) { | ||||
| val mappedType = mapM18ProductType(pro.udfProducttype) | |||||
| // ── Use cache instead of direct call ──────────────────────── | // ── Use cache instead of direct call ──────────────────────── | ||||
| val existingItem = itemCache.getOrPut(item.id) { | val existingItem = itemCache.getOrPut(item.id) { | ||||
| itemsService.findByM18Id(item.id) | itemsService.findByM18Id(item.id) | ||||
| @@ -390,14 +402,7 @@ open class M18MasterDataService( | |||||
| val saveItemRequest = NewItemRequest( | val saveItemRequest = NewItemRequest( | ||||
| code = pro.code, | code = pro.code, | ||||
| name = pro.desc, | name = pro.desc, | ||||
| type = when (pro.udfProducttype) { | |||||
| M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type | |||||
| M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type | |||||
| M18ItemType.FG.type -> ItemType.FG.type | |||||
| M18ItemType.SFG.type -> ItemType.SFG.type | |||||
| M18ItemType.ITEM.type -> ItemType.ITEM.type | |||||
| else -> ItemType.MATERIAL.type | |||||
| }, | |||||
| type = mappedType, | |||||
| id = existingItem?.id, | id = existingItem?.id, | ||||
| description = pro.desc, | description = pro.desc, | ||||
| remarks = null, | remarks = null, | ||||
| @@ -430,6 +435,13 @@ open class M18MasterDataService( | |||||
| logger.error("saveItem duplicate code for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}") | logger.error("saveItem duplicate code for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}") | ||||
| return@forEach | return@forEach | ||||
| } | } | ||||
| if (ItemM18IdRemapSupport.isM18IdLinkOnly(existingItem == null, savedItem.message)) { | |||||
| logger.warn( | |||||
| "Linked M18 product id=${item.id} code=${pro.code} to existing local item id=$localItemId; skip UOM rebuild" | |||||
| ) | |||||
| successList.add(item.id) | |||||
| return@forEach | |||||
| } | |||||
| logger.info("Processing item uom...") | logger.info("Processing item uom...") | ||||
| val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | ||||
| @@ -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 | * Uses delivery_order_pick_order (handler = handledBy); time = sum of | ||||
| * (ticketCompleteDateTime - ticketReleaseTime) per completed ticket. | * (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). | * 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). | * 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). | * 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() | args["endExclusive"] = endDate.plusDays(1).atStartOfDay() | ||||
| "AND dop.ticketCompleteDateTime < :endExclusive" | "AND dop.ticketCompleteDateTime < :endExclusive" | ||||
| } else "" | } 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 "" | } else "" | ||||
| val storeSql = when { | val storeSql = when { | ||||
| storeIdNull == true -> "AND dop.storeId IS NULL" | storeIdNull == true -> "AND dop.storeId IS NULL" | ||||
| @@ -758,31 +763,61 @@ open class ChartService( | |||||
| else -> "" | else -> "" | ||||
| } | } | ||||
| val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank() | val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank() | ||||
| val fromClause = if (useStoreFilter) { | |||||
| val dopFromClause = if (useStoreFilter) { | |||||
| "FROM delivery_order_pick_order dop" | "FROM delivery_order_pick_order dop" | ||||
| } else { | } else { | ||||
| "FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)" | "FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)" | ||||
| } | } | ||||
| val sql = """ | 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 | 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( | COALESCE(SUM( | ||||
| CASE | 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 | ELSE 0 | ||||
| END | 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 | ORDER BY date, orderCount DESC | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -194,9 +194,13 @@ class ChartController( | |||||
| chartService.getStaffDeliveryPerformanceHandlers() | 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 | * 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). | * Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId). | ||||
| */ | */ | ||||
| @GetMapping("/staff-delivery-performance") | @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 SEND_EMAIL_ERROR = "SEND_EMAIL_ERROR"; | ||||
| public static final String USERNAME_NOT_AVAILABLE = "USERNAME_NOT_AVAILABLE"; | 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"; | 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). | * Warehouse exclude list for workbench **re-suggest** after scan-pick (shortfall / lot split). | ||||
| * - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES]). | * - 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). | * - DO / other: pass through request (`null` → service default excludes). | ||||
| */ | */ | ||||
| private fun workbenchResuggestExcludeWarehouseCodes( | 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): | * Workbench scan-pick (DO FG): | ||||
| * 1) Post outbound on scanned inventory lot line first; on failure return a clear message. | * 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], | * 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( | private fun runWorkbenchPickDeferredFollowUps( | ||||
| solId: Long, | solId: Long, | ||||
| polId: Long, | polId: Long, | ||||
| @@ -2386,10 +2388,20 @@ return MessageResponse( | |||||
| var postMs = 0L | var postMs = 0L | ||||
| try { | try { | ||||
| if (pickOrderId != null) { | 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( | val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( | ||||
| pickOrderId = pickOrderId, | pickOrderId = pickOrderId, | ||||
| poType = null, | |||||
| poType = resolvedPoType, | |||||
| userId = userId, | userId = userId, | ||||
| requestExcludeWarehouseCodes = effectiveExcludeWarehouseCodes, | 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 | * DO user-audit: Just Complete is reportable only when the item still has pickable stock | ||||
| * at complete time (AVAILABLE, not expired, in−out > 0). | * 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 | 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). | * 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 | * [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). | * 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]) | * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) | ||||
| * and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]). | * 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 { | object JoWorkbenchPickConstants { | ||||
| val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf( | val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf( | ||||
| @@ -83,6 +83,7 @@ class LaserBag2AutoSendService( | |||||
| jobOrderId = jo.id, | jobOrderId = jo.id, | ||||
| jobOrderNo = jo.code, | jobOrderNo = jo.code, | ||||
| lotNo = jo.lotNo, | lotNo = jo.lotNo, | ||||
| expiryDate = jo.expiryDate?.toString(), | |||||
| source = "AUTO", | source = "AUTO", | ||||
| ), | ), | ||||
| ) | ) | ||||
| @@ -0,0 +1,45 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.service | |||||
| import java.nio.charset.StandardCharsets | |||||
| /** | |||||
| * OnPack `.image` templates are often UTF-16 LE with BOM (Windows export). | |||||
| * Decode/encode must round-trip the same encoding or SmartDate X40 will refuse the job. | |||||
| */ | |||||
| object OnPackImageTemplateCodec { | |||||
| private val bomUtf16Le = byteArrayOf(0xFF.toByte(), 0xFE.toByte()) | |||||
| private val bomUtf16Be = byteArrayOf(0xFE.toByte(), 0xFF.toByte()) | |||||
| private val bomUtf8 = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) | |||||
| fun decode(bytes: ByteArray): Pair<String, (String) -> ByteArray> { | |||||
| return when { | |||||
| bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] -> { | |||||
| val body = bytes.copyOfRange(2, bytes.size) | |||||
| val text = String(body, StandardCharsets.UTF_16LE) | |||||
| text to { s -> bomUtf16Le + s.toByteArray(StandardCharsets.UTF_16LE) } | |||||
| } | |||||
| bytes.size >= 2 && bytes[0] == bomUtf16Be[0] && bytes[1] == bomUtf16Be[1] -> { | |||||
| val body = bytes.copyOfRange(2, bytes.size) | |||||
| val text = String(body, StandardCharsets.UTF_16BE) | |||||
| text to { s -> bomUtf16Be + s.toByteArray(StandardCharsets.UTF_16BE) } | |||||
| } | |||||
| bytes.size >= 3 && bytes[0] == bomUtf8[0] && bytes[1] == bomUtf8[1] && bytes[2] == bomUtf8[2] -> { | |||||
| val body = bytes.copyOfRange(3, bytes.size) | |||||
| val text = String(body, StandardCharsets.UTF_8) | |||||
| text to { s -> bomUtf8 + s.toByteArray(StandardCharsets.UTF_8) } | |||||
| } | |||||
| bytes.size >= 2 && bytes[0] == 0x3C.toByte() && bytes[1] == 0x00.toByte() -> { | |||||
| val text = String(bytes, StandardCharsets.UTF_16LE) | |||||
| text to { s -> s.toByteArray(StandardCharsets.UTF_16LE) } | |||||
| } | |||||
| else -> { | |||||
| val utf8 = String(bytes, StandardCharsets.UTF_8) | |||||
| utf8 to { s -> s.toByteArray(StandardCharsets.UTF_8) } | |||||
| } | |||||
| } | |||||
| } | |||||
| fun hasUtf16LeBom(bytes: ByteArray): Boolean = | |||||
| bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] | |||||
| } | |||||
| @@ -0,0 +1,152 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.service | |||||
| /** | |||||
| * Puts today's expiry BMP into 汁水機 OnPack `.image` XML without replacing LOGO_3 | |||||
| * (production / print date). | |||||
| * | |||||
| * If the designer template already has an expiry slot (`LOGO_5` or `LOGO_EXP`), that | |||||
| * field's FileName is rewritten and geometry is left as in the XML (no second field). | |||||
| * Old 4-logo templates still get a generated `LOGO_EXP` (QR moved down). | |||||
| */ | |||||
| object OnPackJuiceExpiryXml { | |||||
| private val hasLogo4 = Regex("""<Name>\s*LOGO_4\s*</Name>""") | |||||
| private val logo3X = Regex( | |||||
| """(?s)<Name>\s*LOGO_3\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<X>\s*(\d+)\s*</X>""", | |||||
| ) | |||||
| private val logo3Y = Regex( | |||||
| """(?s)<Name>\s*LOGO_3\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<Y>\s*(\d+)\s*</Y>""", | |||||
| ) | |||||
| private val logo3HeightReplace = Regex( | |||||
| """(?s)(<Name>\s*LOGO_3\s*</Name>[\s\S]*?<Height>)\s*\d+\s*(</Height>)""", | |||||
| ) | |||||
| private val logo4Height = Regex( | |||||
| """(?s)<Name>\s*LOGO_4\s*</Name>[\s\S]*?<Height>\s*(\d+)\s*</Height>""", | |||||
| ) | |||||
| private val logo4YReplace = Regex( | |||||
| """(?s)(<Name>\s*LOGO_4\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<Y>)\s*\d+\s*(</Y>)""", | |||||
| ) | |||||
| private val logo4HeightReplace = Regex( | |||||
| """(?s)(<Name>\s*LOGO_4\s*</Name>[\s\S]*?<Height>)\s*\d+\s*(</Height>)""", | |||||
| ) | |||||
| private val canvasSize = Regex( | |||||
| """(?s)<Width>\s*(\d+)\s*</Width>\s*<Height>\s*(\d+)\s*</Height>\s*<FieldList""", | |||||
| ) | |||||
| private val logoExpBlock = Regex( | |||||
| """(?s)<Logo[^>]*>\s*<Name>\s*LOGO_EXP\s*</Name>[\s\S]*?</Logo>""", | |||||
| ) | |||||
| private val logo4Open = Regex( | |||||
| """(?s)(<Logo[^>]*>\s*<Name>\s*LOGO_4\s*</Name>)""", | |||||
| ) | |||||
| fun applyExpiry(xml: String, bmpFileName: String, bmpPixelWidth: Int): String { | |||||
| val name = OnPackXml.escapeText(bmpFileName.trim()) | |||||
| if (name.isEmpty() || !xml.contains("</FieldList>")) { | |||||
| return xml | |||||
| } | |||||
| existingExpirySlotName(xml)?.let { slot -> | |||||
| return rewriteLogoFileName(xml, slot, name) | |||||
| } | |||||
| if (!hasLogo4.containsMatchIn(xml)) { | |||||
| return xml | |||||
| } | |||||
| val layout = layoutFor(xml, bmpPixelWidth) | |||||
| var out = logo3HeightReplace.replaceFirst(xml, "$1${layout.dateHeight}$2") | |||||
| out = logo4YReplace.replaceFirst(out, "$1${layout.qrY}$2") | |||||
| out = logo4HeightReplace.replaceFirst(out, "$1${layout.qrHeight}$2") | |||||
| val field = expiryLogoXml( | |||||
| layout.x, | |||||
| layout.expY, | |||||
| name, | |||||
| layout.expWidth, | |||||
| layout.expHeight, | |||||
| OnPackXml.nextId(xml), | |||||
| ) | |||||
| return if (logoExpBlock.containsMatchIn(out)) { | |||||
| logoExpBlock.replace(out, field) | |||||
| } else { | |||||
| insertLogoExp(out, field) | |||||
| } | |||||
| } | |||||
| /** Designer expiry slot: prefer LOGO_EXP, then LOGO_5 (CoLOS export). */ | |||||
| fun existingExpirySlotName(xml: String): String? { | |||||
| if (Regex("""<Name>\s*LOGO_EXP\s*</Name>""").containsMatchIn(xml)) return "LOGO_EXP" | |||||
| if (Regex("""<Name>\s*LOGO_5\s*</Name>""").containsMatchIn(xml)) return "LOGO_5" | |||||
| return null | |||||
| } | |||||
| fun rewriteLogoFileName(xml: String, slot: String, fileName: String): String { | |||||
| val re = Regex( | |||||
| """(?s)(<Name>\s*${Regex.escape(slot)}\s*</Name>[\s\S]*?<FileName>)([^<]*)(</FileName>)""", | |||||
| ) | |||||
| return re.replaceFirst(xml, "$1$fileName$3") | |||||
| } | |||||
| internal fun layoutFor(xml: String, bmpPixelWidth: Int): JuiceExpiryLayout { | |||||
| val x = logo3X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 500 | |||||
| val logo3YVal = logo3Y.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2500 | |||||
| val qrH0 = logo4Height.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 3000 | |||||
| val canvas = canvasSize.find(xml) | |||||
| val canvasW = canvas?.groupValues?.get(1)?.toIntOrNull() ?: 5300 | |||||
| val canvasH = canvas?.groupValues?.get(2)?.toIntOrNull() ?: 7100 | |||||
| val dateHeight = DATE_HEIGHT_UNITS | |||||
| val expHeight = EXP_HEIGHT_UNITS | |||||
| val expY = logo3YVal + dateHeight + GAP_AFTER_DATE | |||||
| val qrBottomMax = (canvasH - 80).coerceAtLeast(expY + expHeight + 40) | |||||
| var qrY = expY + expHeight + GAP_AFTER_EXP | |||||
| if (qrY >= qrBottomMax) { | |||||
| qrY = (expY + expHeight + 80).coerceAtMost(qrBottomMax - 1) | |||||
| } | |||||
| val qrHeight = qrH0.coerceAtMost((qrBottomMax - qrY).coerceAtLeast(1)) | |||||
| val expWidth = (bmpPixelWidth * expHeight / BMP_TARGET_HEIGHT).coerceIn(1800, 4000) | |||||
| .coerceAtMost((canvasW - x).coerceAtLeast(200)) | |||||
| return JuiceExpiryLayout( | |||||
| x = x, | |||||
| dateHeight = dateHeight, | |||||
| expY = expY, | |||||
| expWidth = expWidth, | |||||
| expHeight = expHeight, | |||||
| qrY = qrY, | |||||
| qrHeight = qrHeight, | |||||
| canvasWidth = canvasW, | |||||
| canvasHeight = canvasH, | |||||
| ) | |||||
| } | |||||
| private fun insertLogoExp(xml: String, field: String): String { | |||||
| if (logo4Open.containsMatchIn(xml)) { | |||||
| return logo4Open.replaceFirst(xml, "$field$1") | |||||
| } | |||||
| return xml.replaceFirst("</FieldList>", "$field</FieldList>") | |||||
| } | |||||
| private fun expiryLogoXml( | |||||
| x: Int, | |||||
| y: Int, | |||||
| bmpFileName: String, | |||||
| width: Int, | |||||
| height: Int, | |||||
| id: Int, | |||||
| ): String { | |||||
| return """<Logo type='Logo.V1'><Name>LOGO_EXP</Name><ID>$id</ID><Geometry><X>$x</X><Y>$y</Y><Rotation>0</Rotation></Geometry><FieldColor>BLACK</FieldColor><FileName>$bmpFileName</FileName><Width>$width</Width><Height>$height</Height></Logo>""" | |||||
| } | |||||
| private const val DATE_HEIGHT_UNITS = 640 | |||||
| private const val EXP_HEIGHT_UNITS = 260 | |||||
| private const val GAP_AFTER_DATE = 80 | |||||
| private const val GAP_AFTER_EXP = 800 | |||||
| private const val BMP_TARGET_HEIGHT = 180 | |||||
| } | |||||
| internal data class JuiceExpiryLayout( | |||||
| val x: Int, | |||||
| val dateHeight: Int, | |||||
| val expY: Int, | |||||
| val expWidth: Int, | |||||
| val expHeight: Int, | |||||
| val qrY: Int, | |||||
| val qrHeight: Int, | |||||
| val canvasWidth: Int = 5300, | |||||
| val canvasHeight: Int = 7100, | |||||
| ) | |||||
| @@ -0,0 +1,61 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.service | |||||
| /** | |||||
| * Injects an expiry text field into OnPack2023 lemon `.image` XML without replacing | |||||
| * TEXT_3 (production / print date, TimeDate offset 0). | |||||
| * | |||||
| * Lot-like TEXT_2 is at a higher X; TEXT_3 is lower X (90° rotation). Expiry sits between them. | |||||
| */ | |||||
| object OnPackLemonExpiryXml { | |||||
| private val text2X = Regex( | |||||
| """(?s)<Name>\s*TEXT_2\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<X>\s*(\d+)\s*</X>""", | |||||
| ) | |||||
| private val text3X = Regex( | |||||
| """(?s)<Name>\s*TEXT_3\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<X>\s*(\d+)\s*</X>""", | |||||
| ) | |||||
| private val text2Y = Regex( | |||||
| """(?s)<Name>\s*TEXT_2\s*</Name>[\s\S]*?<Geometry>[\s\S]*?<Y>\s*(\d+)\s*</Y>""", | |||||
| ) | |||||
| private val text2PointSize = Regex( | |||||
| """(?s)(<Name>\s*TEXT_2\s*</Name>[\s\S]*?<PointSizeInHM>)(\d+)(</PointSizeInHM>)""", | |||||
| ) | |||||
| private val textExpStatic = Regex( | |||||
| """(?s)(<Name>\s*TEXT_EXP\s*</Name>[\s\S]*?<Static\s+type=(?:'|\u0022)StaticSrc\.V1(?:'|\u0022)[^>]*>\s*<Text>)([^<]*)(</Text>)""", | |||||
| ) | |||||
| private val hasText3 = Regex("""<Name>\s*TEXT_3\s*</Name>""") | |||||
| fun applyExpiry(xml: String, expiryText: String): String { | |||||
| val compact = OnPackXml.escapeText(expiryText.trim()) | |||||
| if (compact.isEmpty() || !hasText3.containsMatchIn(xml) || !xml.contains("</FieldList>")) { | |||||
| return xml | |||||
| } | |||||
| val smallerLot = shrinkText2(xml) | |||||
| if (textExpStatic.containsMatchIn(smallerLot)) { | |||||
| return textExpStatic.replace(smallerLot) { m -> | |||||
| m.groupValues[1] + compact + m.groupValues[3] | |||||
| } | |||||
| } | |||||
| return insertTextExp(smallerLot, compact) | |||||
| } | |||||
| internal fun shrinkText2(xml: String): String { | |||||
| return text2PointSize.replaceFirst(xml, "$1${TEXT_2_POINT_SIZE}$3") | |||||
| } | |||||
| private fun insertTextExp(xml: String, expiryYyyymmdd: String): String { | |||||
| val lotX = text2X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2900 | |||||
| val dateX = text3X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2000 | |||||
| val y = text2Y.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2250 | |||||
| val expX = (lotX + dateX) / 2 | |||||
| val field = expiryFieldXml(expX, y, expiryYyyymmdd, OnPackXml.nextId(xml)) | |||||
| return xml.replaceFirst("</FieldList>", "$field</FieldList>") | |||||
| } | |||||
| private fun expiryFieldXml(x: Int, y: Int, expiryText: String, id: Int): String { | |||||
| return """<AlphaNumeric type='AlphaNum.V1'><Name>TEXT_EXP</Name><ID>$id</ID><Geometry><X>$x</X><Y>$y</Y><Rotation>90</Rotation></Geometry><FieldColor>BLACK</FieldColor><Logged>false</Logged><Text type='TextBased.V1'><Inverted>false</Inverted><OverlayMode>MERGE</OverlayMode><Font type='Font.V1'><Typeface>Arial</Typeface><PointSizeInHM>560</PointSizeInHM><Filename>arialbd.ttf</Filename><WidthScaleFactor>93</WidthScaleFactor></Font></Text><Static type='StaticSrc.V1'><Text>$expiryText</Text></Static></AlphaNumeric>""" | |||||
| } | |||||
| private const val TEXT_2_POINT_SIZE = "520" | |||||
| } | |||||
| @@ -0,0 +1,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.LaserRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse | import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest | import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest | ||||
| import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService | |||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | import com.ffii.fpsms.modules.master.service.ItemUomService | ||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | import com.ffii.fpsms.modules.settings.service.SettingsService | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| @@ -56,10 +57,25 @@ import java.time.Duration | |||||
| import java.time.Instant | import java.time.Instant | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.ZoneId | import java.time.ZoneId | ||||
| import java.time.format.DateTimeFormatter | |||||
| // Data class to store bitmap bytes + width (for XML) | // Data class to store bitmap bytes + width (for XML) | ||||
| data class BitmapResult(val bytes: ByteArray, val width: Int) | data class BitmapResult(val bytes: ByteArray, val width: Int) | ||||
| 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]). */ | /** One Bag2-style laser TCP attempt (internal to [PlasticBagPrinterService]). */ | ||||
| private data class LaserBag2TcpResult( | private data class LaserBag2TcpResult( | ||||
| val success: Boolean, | val success: Boolean, | ||||
| @@ -75,10 +91,12 @@ class PlasticBagPrinterService( | |||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, | |||||
| private val settingsService: SettingsService, | private val settingsService: SettingsService, | ||||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | ||||
| private val environment: Environment, | private val environment: Environment, | ||||
| private val objectMapper: ObjectMapper, | private val objectMapper: ObjectMapper, | ||||
| private val onPackTemplateFileService: OnPackTemplateFileService, | |||||
| ) { | ) { | ||||
| private val logger = LoggerFactory.getLogger(javaClass) | private val logger = LoggerFactory.getLogger(javaClass) | ||||
| private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong") | private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong") | ||||
| @@ -88,6 +106,70 @@ class PlasticBagPrinterService( | |||||
| private const val DEFAULT_LASER_BAG2_PORT = 45678 | private const val DEFAULT_LASER_BAG2_PORT = 45678 | ||||
| private const val DEFAULT_LASER_ITEM_CODES = "PP1175" | private const val DEFAULT_LASER_ITEM_CODES = "PP1175" | ||||
| private const val PACKAGING_PROCESS_NAME = "包裝" | private const val PACKAGING_PROCESS_NAME = "包裝" | ||||
| /** Designer product BMP canvas (LOGO), 1-bit. */ | |||||
| private const val ONPACK_PRODUCT_BMP_WIDTH = 593 | |||||
| private const val ONPACK_PRODUCT_BMP_HEIGHT = 90 | |||||
| /** Designer item-code BMP canvas (LOGO_2), 1-bit. */ | |||||
| private const val ONPACK_CODE_BMP_WIDTH = 385 | |||||
| private const val ONPACK_CODE_BMP_HEIGHT = 141 | |||||
| /** Designer production-date BMP canvas (LOGO_3), 1-bit. */ | |||||
| private const val ONPACK_DATE_BMP_WIDTH = 505 | |||||
| private const val ONPACK_DATE_BMP_HEIGHT = 141 | |||||
| /** Designer expiry BMP canvas (LOGO_5), 1-bit. */ | |||||
| private const val ONPACK_EXPIRY_BMP_WIDTH = 623 | |||||
| private const val ONPACK_EXPIRY_BMP_HEIGHT = 79 | |||||
| /** | |||||
| * 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 { | fun getLaserBag2Settings(): LaserBag2SettingsResponse { | ||||
| @@ -176,8 +258,22 @@ class PlasticBagPrinterService( | |||||
| } | } | ||||
| val ids = filtered.mapNotNull { it.id } | val ids = filtered.mapNotNull { it.id } | ||||
| val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | ||||
| val printDate = ItemDefaultShelfLifeService.today() | |||||
| val shelfLifeByCode = itemDefaultShelfLifeService.printShelfLifeByItemCodes( | |||||
| filtered.map { it.bom?.item?.code ?: it.bom?.code }, | |||||
| ) | |||||
| return filtered.map { jo -> | return filtered.map { jo -> | ||||
| PyJobOrderListMapper.toLaserListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) | |||||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | |||||
| val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(itemCode, shelfLifeByCode, printDate) | |||||
| PyJobOrderListMapper.toLaserListItem( | |||||
| jo, | |||||
| printed[jo.id!!], | |||||
| stockInLineRepository, | |||||
| itemUomService, | |||||
| defaultShelfLifeDays = days, | |||||
| useMinus18 = useMinus18, | |||||
| expiryDate = expiry, | |||||
| ) | |||||
| } | } | ||||
| } | } | ||||
| @@ -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. | * 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 { | 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() } | val ip = (request.printerIp?.trim()?.takeIf { it.isNotEmpty() } | ||||
| ?: resolveLaserBag2Host()).trim() | ?: resolveLaserBag2Host()).trim() | ||||
| val port = request.printerPort ?: resolveLaserBag2Port() | val port = request.printerPort ?: resolveLaserBag2Port() | ||||
| @@ -203,32 +311,28 @@ class PlasticBagPrinterService( | |||||
| stockInLineId = request.stockInLineId, | stockInLineId = request.stockInLineId, | ||||
| itemCode = request.itemCode, | itemCode = request.itemCode, | ||||
| itemName = request.itemName, | itemName = request.itemName, | ||||
| expiryDate = request.expiryDate, | |||||
| ) | ) | ||||
| val response = if (first.success) { | |||||
| LaserBag2SendResponse( | |||||
| success = true, | |||||
| message = first.message, | |||||
| payloadSent = first.payload, | |||||
| printerAck = first.printerAck, | |||||
| receiveAcknowledged = first.receiveAcknowledged, | |||||
| ) | |||||
| val chosen = if (first.success) { | |||||
| first | |||||
| } else { | } else { | ||||
| val second = sendLaserBag2TcpOnce( | |||||
| sendLaserBag2TcpOnce( | |||||
| ip = ip, | ip = ip, | ||||
| port = port, | port = port, | ||||
| itemId = request.itemId, | itemId = request.itemId, | ||||
| stockInLineId = request.stockInLineId, | stockInLineId = request.stockInLineId, | ||||
| itemCode = request.itemCode, | itemCode = request.itemCode, | ||||
| itemName = request.itemName, | 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) { | if (response.success && response.receiveAcknowledged) { | ||||
| try { | try { | ||||
| persistLaserLastReceiveSuccess(request, response.printerAck) | persistLaserLastReceiveSuccess(request, response.printerAck) | ||||
| @@ -292,14 +396,9 @@ class PlasticBagPrinterService( | |||||
| stockInLineId: Long?, | stockInLineId: Long?, | ||||
| itemCode: String?, | itemCode: String?, | ||||
| itemName: String?, | itemName: String?, | ||||
| expiryDate: String? = null, | |||||
| ): LaserBag2TcpResult { | ): LaserBag2TcpResult { | ||||
| val codeStr = (itemCode ?: "").trim().replace(";", ",") | |||||
| val nameStr = (itemName ?: "").trim().replace(";", ",") | |||||
| val payload = if (itemId != null && stockInLineId != null) { | |||||
| "{\"itemId\":$itemId,\"stockInLineId\":$stockInLineId};$codeStr;$nameStr;;" | |||||
| } else { | |||||
| "0;$codeStr;$nameStr;;" | |||||
| } | |||||
| val payload = buildLaserBag2Payload(itemId, stockInLineId, itemCode, itemName, expiryDate) | |||||
| val bytes = payload.toByteArray(StandardCharsets.UTF_8) | val bytes = payload.toByteArray(StandardCharsets.UTF_8) | ||||
| var socket: Socket? = null | var socket: Socket? = null | ||||
| try { | try { | ||||
| @@ -346,6 +445,15 @@ class PlasticBagPrinterService( | |||||
| } catch (_: SocketTimeoutException) { | } catch (_: SocketTimeoutException) { | ||||
| // Same as Python Bag3: ignore read timeout, payload was still sent | // 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) { | val msg = if (receiveAck) { | ||||
| "已送出激光機:$payload(已確認)" | "已送出激光機:$payload(已確認)" | ||||
| } else { | } else { | ||||
| @@ -564,7 +672,18 @@ class PlasticBagPrinterService( | |||||
| return baos.toByteArray() | return baos.toByteArray() | ||||
| } | } | ||||
| fun generateOnPackQrZip(jobOrders: List<OnPackQrJobOrderRequest>): ByteArray { | |||||
| /** | |||||
| * 汁水機 OnPack: templates under classpath `onpack2030/{code}.image`. | |||||
| * Always swaps LOGO_4 to the generated QR BMP. | |||||
| * When [includeExpiry] is true, 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 | val normalizedJobOrders = jobOrders | ||||
| .map { | .map { | ||||
| OnPackQrJobOrderRequest( | OnPackQrJobOrderRequest( | ||||
| @@ -600,6 +719,12 @@ class PlasticBagPrinterService( | |||||
| val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds } | val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds } | ||||
| require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" } | require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" } | ||||
| val expiryPrintNames = if (includeExpiry) { | |||||
| onPackTemplateFileService.expiryPrintNames(OnPackTemplateFileService.MACHINE_JUICE) | |||||
| } else { | |||||
| emptyMap() | |||||
| } | |||||
| val exportItemsRaw = packagingJobOrders | val exportItemsRaw = packagingJobOrders | ||||
| .groupBy { it.itemCode.trim().lowercase() } | .groupBy { it.itemCode.trim().lowercase() } | ||||
| .mapNotNull { (codeLower, orders) -> | .mapNotNull { (codeLower, orders) -> | ||||
| @@ -608,31 +733,146 @@ class PlasticBagPrinterService( | |||||
| ?: return@mapNotNull null | ?: return@mapNotNull null | ||||
| val itemId = stockInLine.item?.id ?: return@mapNotNull null | val itemId = stockInLine.item?.id ?: return@mapNotNull null | ||||
| val stockInLineId = stockInLine.id ?: return@mapNotNull null | val stockInLineId = stockInLine.id ?: return@mapNotNull null | ||||
| Triple(codeLower, itemId, stockInLineId) | |||||
| val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null) | |||||
| val baseName = jo?.bom?.name ?: stockInLine.item?.name | |||||
| val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc | |||||
| val 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" } | 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() | val baos = ByteArrayOutputStream() | ||||
| ZipOutputStream(baos).use { zos -> | ZipOutputStream(baos).use { zos -> | ||||
| val addedEntries = linkedSetOf<String>() | val addedEntries = linkedSetOf<String>() | ||||
| exportItems.forEach { (codeLower, itemId, stockInLineId) -> | |||||
| val imageTemplate = loadOnPackImageTemplateOrNull(codeLower) ?: return@forEach | |||||
| exportItems.forEach { item -> | |||||
| val codeLower = item.codeLower | |||||
| val imageTemplate = 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. | // Target approximately 470x389 BMP, but with larger visible QR and very little vertical whitespace. | ||||
| // Width = 386 + (42 * 2) = 470 | // Width = 386 + (42 * 2) = 470 | ||||
| // Height = 386 + (1 * 2) = 388 (~389) | // Height = 386 + (1 * 2) = 388 (~389) | ||||
| val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1) | val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1) | ||||
| val qrBmpFileName = "${codeLower}qr.bmp" | val qrBmpFileName = "${codeLower}qr.bmp" | ||||
| val imageFileName = "$codeLower.image" | val imageFileName = "$codeLower.image" | ||||
| val imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName) | |||||
| var imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName) | |||||
| if (includeExpiry) { | |||||
| val productFile = "${codeLower}Product.bmp" | |||||
| val codeFile = "${codeLower}Code.bmp" | |||||
| val dateFile = "${codeLower}Date.bmp" | |||||
| val productBmp = createMonochromeBitmapFixed( | |||||
| item.productName, | |||||
| ONPACK_PRODUCT_BMP_WIDTH, | |||||
| ONPACK_PRODUCT_BMP_HEIGHT, | |||||
| ) | |||||
| val codeBmp = createMonochromeBitmapFixed( | |||||
| item.itemCode, | |||||
| ONPACK_CODE_BMP_WIDTH, | |||||
| ONPACK_CODE_BMP_HEIGHT, | |||||
| ) | |||||
| val dateBmp = createMonochromeBitmapFixed( | |||||
| ItemDefaultShelfLifeService.formatProductionDatePrintLabel(effectivePrintDate), | |||||
| ONPACK_DATE_BMP_WIDTH, | |||||
| ONPACK_DATE_BMP_HEIGHT, | |||||
| ) | |||||
| imageContent = withOnPackLogoFile(imageContent, "LOGO", productFile) | |||||
| imageContent = withOnPackLogoFile(imageContent, "LOGO_2", codeFile) | |||||
| imageContent = withOnPackLogoFile(imageContent, "LOGO_3", dateFile) | |||||
| if (addedEntries.add(productFile)) { | |||||
| addToZip(zos, productFile, productBmp.bytes) | |||||
| } | |||||
| if (addedEntries.add(codeFile)) { | |||||
| addToZip(zos, codeFile, codeBmp.bytes) | |||||
| } | |||||
| if (addedEntries.add(dateFile)) { | |||||
| addToZip(zos, dateFile, dateBmp.bytes) | |||||
| } | |||||
| } | |||||
| val expiryLabel = if (includeExpiry) { | |||||
| itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| if (!expiryLabel.isNullOrBlank()) { | |||||
| val expBmp = createMonochromeBitmapFixed( | |||||
| expiryLabel, | |||||
| ONPACK_EXPIRY_BMP_WIDTH, | |||||
| ONPACK_EXPIRY_BMP_HEIGHT, | |||||
| ) | |||||
| val expBmpFileName = "${codeLower}exp.bmp" | |||||
| imageContent = withOnPackExpiryLogo(imageContent, expBmpFileName, expBmp.width) | |||||
| if (addedEntries.add(expBmpFileName)) { | |||||
| addToZip(zos, expBmpFileName, expBmp.bytes) | |||||
| } | |||||
| } | |||||
| if (addedEntries.add(qrBmpFileName)) { | if (addedEntries.add(qrBmpFileName)) { | ||||
| addToZip(zos, qrBmpFileName, bmp.bytes) | addToZip(zos, qrBmpFileName, bmp.bytes) | ||||
| @@ -640,19 +880,50 @@ class PlasticBagPrinterService( | |||||
| if (addedEntries.add(imageFileName)) { | if (addedEntries.add(imageFileName)) { | ||||
| addToZip(zos, imageFileName, imageContent) | 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" } | 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). | * OnPack2023 檸檬機: templates under classpath `onpack2030_2/{code}.image` with embedded QR (Static text). | ||||
| * Only replaces `<Text>...</Text>` under `<Static type="StaticSrc.V1">` with JSON payload. No separate .bmp in zip. | * Only replaces `<Text>...</Text>` under `<Static type="StaticSrc.V1">` with JSON payload. No separate .bmp in zip. | ||||
| * When [includeExpiry] is true, also injects TEXT_EXP (does not change TEXT_3 production date). Old ZIP callers must pass false. | |||||
| */ | */ | ||||
| fun generateOnPackQrTextZip(jobOrders: List<OnPackQrJobOrderRequest>): ByteArray { | |||||
| fun generateOnPackQrTextZip( | |||||
| jobOrders: List<OnPackQrJobOrderRequest>, | |||||
| includeExpiry: Boolean = false, | |||||
| printDate: LocalDate? = null, | |||||
| ): OnPackZipResult { | |||||
| val normalizedJobOrders = jobOrders | val normalizedJobOrders = jobOrders | ||||
| .map { | .map { | ||||
| OnPackQrJobOrderRequest( | OnPackQrJobOrderRequest( | ||||
| @@ -702,20 +973,56 @@ class PlasticBagPrinterService( | |||||
| val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet() | val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet() | ||||
| val allowedTextCodes = codesOnPackMatchingTemplateType(codesUpper, "text") | 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() | val baos = ByteArrayOutputStream() | ||||
| ZipOutputStream(baos).use { zos -> | ZipOutputStream(baos).use { zos -> | ||||
| val addedEntries = linkedSetOf<String>() | val addedEntries = linkedSetOf<String>() | ||||
| exportItems.forEach { (codeLower, itemId, stockInLineId) -> | exportItems.forEach { (codeLower, itemId, stockInLineId) -> | ||||
| val imageTemplate = loadOnPack2030_2ImageTemplateOrNull(codeLower) ?: run { | val imageTemplate = loadOnPack2030_2ImageTemplateOrNull(codeLower) ?: run { | ||||
| logger.warn("OnPack text ZIP: missing classpath template onpack2030_2/{}.image", codeLower.uppercase()) | |||||
| logger.warn("OnPack text ZIP: missing template for {}", codeLower.uppercase()) | |||||
| return@forEach | return@forEach | ||||
| } | } | ||||
| val imageFileName = "${codeLower.uppercase()}.image" | val imageFileName = "${codeLower.uppercase()}.image" | ||||
| val imageContent = withOnPackStaticQrText(codeLower, imageTemplate, itemId, stockInLineId) | |||||
| val expiryLabel = if (includeExpiry) { | |||||
| itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| val imageContent = withOnPackStaticQrText( | |||||
| codeLower, | |||||
| imageTemplate, | |||||
| itemId, | |||||
| stockInLineId, | |||||
| expiryCompact = expiryLabel, | |||||
| ) | |||||
| if (addedEntries.add(imageFileName)) { | if (addedEntries.add(imageFileName)) { | ||||
| addToZip(zos, imageFileName, imageContent) | addToZip(zos, imageFileName, imageContent) | ||||
| } | } | ||||
| @@ -723,7 +1030,7 @@ class PlasticBagPrinterService( | |||||
| extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> | extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> | ||||
| if (!addedEntries.add(bmpName)) return@forEach | if (!addedEntries.add(bmpName)) return@forEach | ||||
| val bmpBytes = loadOnPack2030_2AssetOrNull(bmpName) ?: run { | val bmpBytes = loadOnPack2030_2AssetOrNull(bmpName) ?: run { | ||||
| logger.warn("OnPack text ZIP: missing classpath asset onpack2030_2/{}", bmpName) | |||||
| logger.warn("OnPack text ZIP: missing asset {}", bmpName) | |||||
| return@forEach | return@forEach | ||||
| } | } | ||||
| addToZip(zos, bmpName, bmpBytes) | addToZip(zos, bmpName, bmpBytes) | ||||
| @@ -737,7 +1044,7 @@ class PlasticBagPrinterService( | |||||
| } | } | ||||
| require(addedEntries.isNotEmpty()) { "No OnPack text template files could be generated for the selected date" } | 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 { | val zipBytes = try { | ||||
| generateOnPackQrTextZip(jobOrders) | |||||
| generateOnPackQrTextZip(jobOrders).bytes | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| logger.warn("OnPack text ZIP generation failed before NGPCL push", e) | logger.warn("OnPack text ZIP generation failed before NGPCL push", e) | ||||
| return NgpclPushResponse( | return NgpclPushResponse( | ||||
| @@ -816,10 +1123,46 @@ class PlasticBagPrinterService( | |||||
| else -> return emptySet() | else -> return emptySet() | ||||
| } | } | ||||
| val rows = jdbcDao.queryForList(sql, mapOf("codes" to codesUpper.toList())) | val rows = jdbcDao.queryForList(sql, mapOf("codes" to codesUpper.toList())) | ||||
| return rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet() | |||||
| val fromTable = rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet() | |||||
| val machine = if (normalizedType == "text") { | |||||
| OnPackTemplateFileService.MACHINE_LEMON | |||||
| } else { | |||||
| OnPackTemplateFileService.MACHINE_JUICE | |||||
| } | |||||
| val fromDbFiles = onPackTemplateFileService.itemCodesWithImage(machine) | |||||
| .filter { it in codesUpper } | |||||
| .toSet() | |||||
| return fromTable + fromDbFiles | |||||
| } | |||||
| private fun 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 resourcePath = "onpack2030/${codeLower}.image" | ||||
| val resource = ClassPathResource(resourcePath) | val resource = ClassPathResource(resourcePath) | ||||
| if (!resource.exists()) return null | if (!resource.exists()) return null | ||||
| @@ -828,6 +1171,7 @@ class PlasticBagPrinterService( | |||||
| /** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */ | /** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */ | ||||
| private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? { | private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? { | ||||
| onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_LEMON, codeLower)?.let { return it } | |||||
| val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image" | val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image" | ||||
| val resource = ClassPathResource(resourcePath) | val resource = ClassPathResource(resourcePath) | ||||
| if (!resource.exists()) return null | if (!resource.exists()) return null | ||||
| @@ -836,11 +1180,24 @@ class PlasticBagPrinterService( | |||||
| private fun loadOnPack2030_2AssetOrNull(fileName: String): ByteArray? { | private fun loadOnPack2030_2AssetOrNull(fileName: String): ByteArray? { | ||||
| val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null } | val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null } | ||||
| onPackTemplateFileService.loadAsset(OnPackTemplateFileService.MACHINE_LEMON, safe)?.let { return it } | |||||
| val resource = ClassPathResource("onpack2030_2/$safe") | val resource = ClassPathResource("onpack2030_2/$safe") | ||||
| if (!resource.exists()) return null | if (!resource.exists()) return null | ||||
| return resource.inputStream.use { it.readBytes() } | return resource.inputStream.use { it.readBytes() } | ||||
| } | } | ||||
| private fun loadJuiceAssetOrNull(fileName: String): ByteArray? { | |||||
| val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null } | |||||
| onPackTemplateFileService.loadAsset(OnPackTemplateFileService.MACHINE_JUICE, safe)?.let { return it } | |||||
| listOf("onpack2030_exp/$safe", "onpack2030/$safe").forEach { path -> | |||||
| val resource = ClassPathResource(path) | |||||
| if (resource.exists()) { | |||||
| return resource.inputStream.use { it.readBytes() } | |||||
| } | |||||
| } | |||||
| return null | |||||
| } | |||||
| /** Collect `<FileName>xxx.bmp</FileName>` inside each `<Logo>...</Logo>` block (decoded template XML). */ | /** Collect `<FileName>xxx.bmp</FileName>` inside each `<Logo>...</Logo>` block (decoded template XML). */ | ||||
| private fun extractLogoBmpFileNamesFromOnPackImageXml(xml: String): Set<String> { | private fun extractLogoBmpFileNamesFromOnPackImageXml(xml: String): Set<String> { | ||||
| val out = linkedSetOf<String>() | val out = linkedSetOf<String>() | ||||
| @@ -853,14 +1210,17 @@ class PlasticBagPrinterService( | |||||
| } | } | ||||
| private fun withOnPackLogo4Bmp(imageBytes: ByteArray, qrBmpFileName: String): ByteArray { | private fun withOnPackLogo4Bmp(imageBytes: ByteArray, qrBmpFileName: String): ByteArray { | ||||
| // Use ISO-8859-1 one-byte mapping so all original bytes are preserved, | |||||
| // while replacing only ASCII XML fragment for LOGO_4 filename. | |||||
| val oneByteText = String(imageBytes, StandardCharsets.ISO_8859_1) | |||||
| val replaced = oneByteText.replace( | |||||
| Regex("""(<Name>\s*LOGO_4\s*</Name>[\s\S]*?<FileName>)([^<]+)(</FileName>)"""), | |||||
| "$1$qrBmpFileName$3", | |||||
| ) | |||||
| return replaced.toByteArray(StandardCharsets.ISO_8859_1) | |||||
| return withOnPackLogoFile(imageBytes, "LOGO_4", qrBmpFileName) | |||||
| } | |||||
| private fun withOnPackLogoFile(imageBytes: ByteArray, fieldName: String, bmpFileName: String): ByteArray { | |||||
| val (xml, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) | |||||
| return encodeBack(OnPackJuiceExpiryXml.rewriteLogoFileName(xml, fieldName, bmpFileName)) | |||||
| } | |||||
| private fun withOnPackExpiryLogo(imageBytes: ByteArray, bmpFileName: String, bmpWidth: Int): ByteArray { | |||||
| val (xml, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) | |||||
| return encodeBack(OnPackJuiceExpiryXml.applyExpiry(xml, bmpFileName, bmpWidth)) | |||||
| } | } | ||||
| /** | /** | ||||
| @@ -868,39 +1228,16 @@ class PlasticBagPrinterService( | |||||
| * substring/regex matching (`hasNameQr=false` while XML is valid). | * substring/regex matching (`hasNameQr=false` while XML is valid). | ||||
| */ | */ | ||||
| private fun decodeOnPackImageTemplateForTextEdit(bytes: ByteArray): Pair<String, (String) -> ByteArray> { | private fun decodeOnPackImageTemplateForTextEdit(bytes: ByteArray): Pair<String, (String) -> ByteArray> { | ||||
| val bomUtf16Le = byteArrayOf(0xFF.toByte(), 0xFE.toByte()) | |||||
| val bomUtf16Be = byteArrayOf(0xFE.toByte(), 0xFF.toByte()) | |||||
| val bomUtf8 = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) | |||||
| when { | |||||
| bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] -> { | |||||
| val body = bytes.copyOfRange(2, bytes.size) | |||||
| val text = String(body, StandardCharsets.UTF_16LE) | |||||
| return text to { s -> bomUtf16Le + s.toByteArray(StandardCharsets.UTF_16LE) } | |||||
| } | |||||
| bytes.size >= 2 && bytes[0] == bomUtf16Be[0] && bytes[1] == bomUtf16Be[1] -> { | |||||
| val body = bytes.copyOfRange(2, bytes.size) | |||||
| val text = String(body, StandardCharsets.UTF_16BE) | |||||
| return text to { s -> bomUtf16Be + s.toByteArray(StandardCharsets.UTF_16BE) } | |||||
| } | |||||
| bytes.size >= 3 && bytes[0] == bomUtf8[0] && bytes[1] == bomUtf8[1] && bytes[2] == bomUtf8[2] -> { | |||||
| val body = bytes.copyOfRange(3, bytes.size) | |||||
| val text = String(body, StandardCharsets.UTF_8) | |||||
| return text to { s -> bomUtf8 + s.toByteArray(StandardCharsets.UTF_8) } | |||||
| } | |||||
| // UTF-16 LE without BOM: "<" == 0x3C 0x00 | |||||
| bytes.size >= 2 && bytes[0] == 0x3C.toByte() && bytes[1] == 0x00.toByte() -> { | |||||
| val text = String(bytes, StandardCharsets.UTF_16LE) | |||||
| return text to { s -> s.toByteArray(StandardCharsets.UTF_16LE) } | |||||
| } | |||||
| else -> { | |||||
| val utf8 = String(bytes, StandardCharsets.UTF_8) | |||||
| return utf8 to { s -> s.toByteArray(StandardCharsets.UTF_8) } | |||||
| } | |||||
| } | |||||
| return OnPackImageTemplateCodec.decode(bytes) | |||||
| } | } | ||||
| private fun withOnPackStaticQrText(forCode: String, imageBytes: ByteArray, itemId: Long, stockInLineId: Long): ByteArray { | |||||
| private fun withOnPackStaticQrText( | |||||
| forCode: String, | |||||
| imageBytes: ByteArray, | |||||
| itemId: Long, | |||||
| stockInLineId: Long, | |||||
| expiryCompact: String? = null, | |||||
| ): ByteArray { | |||||
| val payload = """{"itemId": $itemId, "stockInLineId": $stockInLineId}""" | val payload = """{"itemId": $itemId, "stockInLineId": $stockInLineId}""" | ||||
| val (oneByteText, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) | val (oneByteText, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) | ||||
| // Must NOT use Static[^>]*StaticSrc — [^>]* already eats type='StaticSrc.V1', so StaticSrc can never match. | // Must NOT use Static[^>]*StaticSrc — [^>]* already eats type='StaticSrc.V1', so StaticSrc can never match. | ||||
| @@ -934,7 +1271,12 @@ class PlasticBagPrinterService( | |||||
| ) | ) | ||||
| } | } | ||||
| val withQrCellWidth = replaceQrV1CellWidth67To50(replaced) | val withQrCellWidth = replaceQrV1CellWidth67To50(replaced) | ||||
| return encodeBack(withQrCellWidth) | |||||
| val withExpiry = if (expiryCompact.isNullOrBlank()) { | |||||
| withQrCellWidth | |||||
| } else { | |||||
| OnPackLemonExpiryXml.applyExpiry(withQrCellWidth, expiryCompact) | |||||
| } | |||||
| return encodeBack(withExpiry) | |||||
| } | } | ||||
| /** First `<CellWidth>67</CellWidth>` inside `<QR type='QR.V1'>` → 50 (export tuning). */ | /** First `<CellWidth>67</CellWidth>` inside `<QR type='QR.V1'>` → 50 (export tuning). */ | ||||
| @@ -952,6 +1294,33 @@ class PlasticBagPrinterService( | |||||
| return if (collapsed.length <= maxLen) collapsed else collapsed.take(maxLen) + "…" | return if (collapsed.length <= maxLen) collapsed else collapsed.take(maxLen) + "…" | ||||
| } | } | ||||
| /** 1-bit BMP at an exact canvas (no crop). Used for OnPack expiry so it matches the designer slot. */ | |||||
| private fun createMonochromeBitmapFixed(text: String, width: Int, height: Int): BitmapResult { | |||||
| val img = BufferedImage(width, height, BufferedImage.TYPE_BYTE_BINARY) | |||||
| img.createGraphics().apply { | |||||
| color = Color.WHITE | |||||
| fillRect(0, 0, width, height) | |||||
| color = Color.BLACK | |||||
| setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF) | |||||
| var fontSize = (height * 0.92).toInt().coerceAtLeast(1) | |||||
| var font = Font("SimSun", Font.BOLD, fontSize) | |||||
| var metrics = getFontMetrics(font) | |||||
| while (fontSize > 8 && metrics.stringWidth(text) > width) { | |||||
| fontSize-- | |||||
| font = Font("SimSun", Font.BOLD, fontSize) | |||||
| metrics = getFontMetrics(font) | |||||
| } | |||||
| this.font = font | |||||
| val x = 0 | |||||
| val y = metrics.ascent + ((height - metrics.height) / 2).coerceAtLeast(0) | |||||
| drawString(text, x, y) | |||||
| dispose() | |||||
| } | |||||
| val baos = ByteArrayOutputStream() | |||||
| ImageIO.write(img, "bmp", baos) | |||||
| return BitmapResult(baos.toByteArray(), img.width) | |||||
| } | |||||
| private fun createMonochromeBitmap(text: String, targetHeight: Int): BitmapResult { | private fun createMonochromeBitmap(text: String, targetHeight: Int): BitmapResult { | ||||
| // Step 1: Measure text width with temporary image | // Step 1: Measure text width with temporary image | ||||
| val tempImg = BufferedImage(1, 1, BufferedImage.TYPE_BYTE_BINARY) | val tempImg = BufferedImage(1, 1, BufferedImage.TYPE_BYTE_BINARY) | ||||
| @@ -0,0 +1,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 | package com.ffii.fpsms.modules.jobOrder.web | ||||
| import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService | 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.service.PlasticBagPrinterService | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | ||||
| @@ -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") | @PostMapping("/print-laser-bag2") | ||||
| fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity<LaserBag2SendResponse> { | 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) { | return if (resp.success) { | ||||
| ResponseEntity.ok(resp) | ResponseEntity.ok(resp) | ||||
| } else { | } else { | ||||
| @@ -118,15 +128,8 @@ class PlasticBagPrinterController( | |||||
| response: HttpServletResponse, | response: HttpServletResponse, | ||||
| ) { | ) { | ||||
| try { | 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) { | } catch (e: IllegalArgumentException) { | ||||
| response.status = HttpServletResponse.SC_BAD_REQUEST | response.status = HttpServletResponse.SC_BAD_REQUEST | ||||
| response.contentType = "text/plain;charset=UTF-8" | 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). */ | /** OnPack2023 檸檬機: `onpack2030_2` templates with embedded QR (Static text only; no separate .bmp). */ | ||||
| @PostMapping("/download-onpack-qr-text") | @PostMapping("/download-onpack-qr-text") | ||||
| fun downloadOnPackQrText( | fun downloadOnPackQrText( | ||||
| @@ -155,15 +196,8 @@ class PlasticBagPrinterController( | |||||
| response: HttpServletResponse, | response: HttpServletResponse, | ||||
| ) { | ) { | ||||
| try { | 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) { | } catch (e: IllegalArgumentException) { | ||||
| response.status = HttpServletResponse.SC_BAD_REQUEST | response.status = HttpServletResponse.SC_BAD_REQUEST | ||||
| response.contentType = "text/plain;charset=UTF-8" | response.contentType = "text/plain;charset=UTF-8" | ||||
| @@ -194,6 +228,43 @@ class PlasticBagPrinterController( | |||||
| return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders)) | return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders)) | ||||
| } | } | ||||
| /** | |||||
| * Same lemon OnPack ZIP as [downloadOnPackQrText], plus TEXT_EXP from item_default_shelf_life. | |||||
| * Does not replace TEXT_3 (production / print date). Old [downloadOnPackQrText] is unchanged. | |||||
| */ | |||||
| @PostMapping("/download-onpack-qr-text-with-expiry") | |||||
| fun downloadOnPackQrTextWithExpiry( | |||||
| @RequestBody request: OnPackQrDownloadRequest, | |||||
| response: HttpServletResponse, | |||||
| ) { | |||||
| try { | |||||
| val 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. | * Test API to generate and download the printer job files as a ZIP. | ||||
| * ONPACK2030 | * 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 | 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 [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port]. | ||||
| * | * | ||||
| * Optional job metadata is used to persist [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS] | * Optional job metadata is used to persist [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS] | ||||
| * when the printer returns a receive ack. | * when the printer returns a receive ack. | ||||
| */ | */ | ||||
| @JsonIgnoreProperties(ignoreUnknown = true) | |||||
| data class LaserBag2SendRequest( | data class LaserBag2SendRequest( | ||||
| val itemId: Long? = null, | val itemId: Long? = null, | ||||
| val stockInLineId: Long? = null, | val stockInLineId: Long? = null, | ||||
| @@ -17,6 +23,12 @@ data class LaserBag2SendRequest( | |||||
| val jobOrderId: Long? = null, | val jobOrderId: Long? = null, | ||||
| val jobOrderNo: String? = null, | val jobOrderNo: String? = null, | ||||
| val lotNo: String? = null, | val lotNo: String? = null, | ||||
| /** | |||||
| * Print-time expiry from the job-order list (`yyyy-MM-dd`, compact `yyyyMMdd`, | |||||
| * 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. */ | /** AUTO (auto-send) or MANUAL (/laserPrint); optional. */ | ||||
| val source: String? = null, | 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( | data class OnPackQrDownloadRequest( | ||||
| val jobOrders: List<OnPackQrJobOrderRequest>, | val jobOrders: List<OnPackQrJobOrderRequest>, | ||||
| /** /bagPrint filter date (job plan date). Used by expiry ZIP for LOGO_3 production date. */ | |||||
| val planDate: java.time.LocalDate? = null, | |||||
| ) | ) | ||||
| data class OnPackQrJobOrderRequest( | data class OnPackQrJobOrderRequest( | ||||
| @@ -0,0 +1,43 @@ | |||||
| package com.ffii.fpsms.modules.master.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.Column | |||||
| import jakarta.persistence.Entity | |||||
| import jakarta.persistence.Table | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Size | |||||
| /** | |||||
| * Shelf life by item code for bag / OnPack expiry print. | |||||
| * [useMinus18]: 0 = print uses [defaultDays] (chilled); 1 = print uses [minus18Days]. | |||||
| */ | |||||
| @Entity | |||||
| @Table(name = "item_default_shelf_life") | |||||
| open class ItemDefaultShelfLife : BaseEntity<Long>() { | |||||
| @NotNull | |||||
| @Size(max = 50) | |||||
| @Column(name = "itemCode", length = 50, nullable = false, unique = true) | |||||
| open var itemCode: String? = null | |||||
| @Column(name = "defaultDays") | |||||
| open var defaultDays: Int? = null | |||||
| @Column(name = "minus18Days") | |||||
| open var minus18Days: Int? = null | |||||
| @NotNull | |||||
| @Column(name = "useMinus18", nullable = false) | |||||
| open var useMinus18: Boolean? = false | |||||
| @Column(name = "openedDays") | |||||
| open var openedDays: Int? = null | |||||
| @Size(max = 20) | |||||
| @Column(name = "storageC", length = 20) | |||||
| open var storageC: String? = null | |||||
| @Size(max = 255) | |||||
| @Column(name = "remarks", length = 255) | |||||
| open var remarks: String? = null | |||||
| } | |||||
| @@ -0,0 +1,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 findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?; | ||||
| fun findByCodeAndDeletedFalse(code: String): Items?; | fun findByCodeAndDeletedFalse(code: String): Items?; | ||||
| fun findByDeletedFalseAndCodeIn(codes: Collection<String>): List<Items> | |||||
| fun findByNameAndDeletedFalse(name: String): Items?; | fun findByNameAndDeletedFalse(name: String): Items?; | ||||
| fun findByM18IdAndDeletedIsFalse(m18Id: Long): Items?; | fun findByM18IdAndDeletedIsFalse(m18Id: Long): Items?; | ||||
| @@ -75,6 +75,7 @@ open class BomService( | |||||
| ) { | ) { | ||||
| companion object { | companion object { | ||||
| private const val BOM_WIP_DESCRIPTION = "WIP" | 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 { | open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse { | ||||
| @@ -1127,7 +1128,8 @@ open class BomService( | |||||
| } | } | ||||
| 3 -> { | 3 -> { | ||||
| val equipmentName = tempCell.stringCellValue.trim() | val equipmentName = tempCell.stringCellValue.trim() | ||||
| if (equipmentName != "不適用") { | |||||
| // 不合用 / 不適用:不掛 equipment(與格式檢查同等處理) | |||||
| if (!isNotApplicableEquipment(equipmentName)) { | |||||
| val equipment = bomGetOrCreateEquipment(equipmentName) | val equipment = bomGetOrCreateEquipment(equipmentName) | ||||
| // println("equipment created") | // println("equipment created") | ||||
| bomProcessRequest.equipment = equipment | 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? { | private fun resolveImportBomFilePath(batchId: String, fileName: String): Path? { | ||||
| val path = getBatchDir(batchId).resolve(fileName) | val path = getBatchDir(batchId).resolve(fileName) | ||||
| return if (Files.exists(path)) path else null | return if (Files.exists(path)) path else null | ||||
| @@ -2467,11 +2777,17 @@ open class BomService( | |||||
| } | } | ||||
| return lines | 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 { | private fun isValidEquipmentType(value: String): Boolean { | ||||
| val trimmed = value.trim() | val trimmed = value.trim() | ||||
| if (trimmed.isEmpty()) return false | if (trimmed.isEmpty()) return false | ||||
| if (trimmed == "不合用" || trimmed == "不適用") return true | |||||
| if (isNotApplicableEquipment(trimmed)) return true | |||||
| if (trimmed.contains(",")) return false // 新增:不允許逗號 | if (trimmed.contains(",")) return false // 新增:不允許逗號 | ||||
| val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機 | val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機 | ||||
| return regex.matches(trimmed) | return regex.matches(trimmed) | ||||
| @@ -3508,8 +3824,11 @@ for (r in 0..20) { | |||||
| processCode = p.process?.code, | processCode = p.process?.code, | ||||
| processName = p.process?.name, | processName = p.process?.name, | ||||
| processDescription = p.description, | processDescription = p.description, | ||||
| byProduct = p.byProduct, | |||||
| byProductUom = p.byProductUom, | |||||
| equipmentCode = p.equipment?.code, | equipmentCode = p.equipment?.code, | ||||
| equipmentName = p.equipment?.name, | equipmentName = p.equipment?.name, | ||||
| equipmentDescription = p.equipment?.description, | |||||
| durationInMinute = p.durationInMinute, | durationInMinute = p.durationInMinute, | ||||
| prepTimeInMinute = p.prepTimeInMinute, | prepTimeInMinute = p.prepTimeInMinute, | ||||
| postProdTimeInMinute = p.postProdTimeInMinute, | 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); | return itemsRepository.findByCodeAndDeletedFalse(code); | ||||
| } | } | ||||
| open fun findByCodeAndType(code: String, type: String): Items? { | |||||
| return itemsRepository.findByCodeAndTypeAndDeletedFalse(code, type) | |||||
| } | |||||
| open fun findByM18Id(m18Id: Long): Items? { | open fun findByM18Id(m18Id: Long): Items? { | ||||
| return itemsRepository.findByM18IdAndDeletedIsFalse(m18Id) | return itemsRepository.findByM18IdAndDeletedIsFalse(m18Id) | ||||
| } | } | ||||
| @@ -703,34 +707,67 @@ open fun listBagItemsCombo(): List<Map<String, String>> { | |||||
| @Transactional | @Transactional | ||||
| open fun saveItem(request: NewItemRequest): MessageResponse { | open fun saveItem(request: NewItemRequest): MessageResponse { | ||||
| val duplicatedItem = itemsRepository.findByCodeAndTypeAndDeletedFalse(request.code, request.type) | val duplicatedItem = itemsRepository.findByCodeAndTypeAndDeletedFalse(request.code, request.type) | ||||
| val ownerOfNewM18Id = request.m18Id?.let { findByM18Id(it) } | |||||
| if (duplicatedItem != null && duplicatedItem.id != request.id) { | if (duplicatedItem != null && duplicatedItem.id != request.id) { | ||||
| if (request.m18Id != null && request.id == null && duplicatedItem.m18Id == null) { | |||||
| duplicatedItem.m18Id = request.m18Id | |||||
| duplicatedItem.m18LastModifyDate = request.m18LastModifyDate | |||||
| val linked = itemsRepository.saveAndFlush(duplicatedItem) | |||||
| val canLink = ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( | |||||
| requestId = request.id, | |||||
| requestM18Id = request.m18Id, | |||||
| duplicatedItemId = duplicatedItem.id, | |||||
| ownerOfNewM18IdItemId = ownerOfNewM18Id?.id, | |||||
| ) | |||||
| if (!canLink) { | |||||
| return MessageResponse( | return MessageResponse( | ||||
| id = linked.id, | |||||
| code = linked.code, | |||||
| name = linked.name, | |||||
| type = linked.type.toString(), | |||||
| message = "Linked m18Id to existing item with same code", | |||||
| errorPosition = null, | |||||
| id = request.id ?: duplicatedItem.id, | |||||
| code = request.code, | |||||
| name = request.name, | |||||
| type = request.type.toString(), | |||||
| message = "The item code has already existed", | |||||
| errorPosition = "code" | |||||
| ) | ) | ||||
| } | } | ||||
| // M18 recode: attach new proId only. Do not overwrite name/QC/UOM via this path. | |||||
| if (duplicatedItem.m18Id != request.m18Id) { | |||||
| logger.warn( | |||||
| "Remapping item m18Id: localId=${duplicatedItem.id} code=${duplicatedItem.code} oldM18Id=${duplicatedItem.m18Id} newM18Id=${request.m18Id}" | |||||
| ) | |||||
| } | |||||
| duplicatedItem.m18Id = request.m18Id | |||||
| duplicatedItem.m18LastModifyDate = request.m18LastModifyDate ?: duplicatedItem.m18LastModifyDate | |||||
| val linked = itemsRepository.saveAndFlush(duplicatedItem) | |||||
| return MessageResponse( | |||||
| id = linked.id, | |||||
| code = linked.code, | |||||
| name = linked.name, | |||||
| type = linked.type.toString(), | |||||
| message = ItemM18IdRemapSupport.LINKED_MESSAGE, | |||||
| errorPosition = null, | |||||
| ) | |||||
| } | |||||
| // Prefer local id so an existing item can have its m18Id updated from the UI. | |||||
| val item = when { | |||||
| request.id != null && request.id > 0 -> | |||||
| itemsRepository.findByIdAndDeletedFalse(request.id) ?: Items() | |||||
| ownerOfNewM18Id != null -> ownerOfNewM18Id | |||||
| else -> Items() | |||||
| } | |||||
| logger.info("item: $item") | |||||
| if (ownerOfNewM18Id != null && ownerOfNewM18Id.id != item.id) { | |||||
| return MessageResponse( | return MessageResponse( | ||||
| id = request.id ?: duplicatedItem.id, | |||||
| id = request.id ?: item.id, | |||||
| code = request.code, | code = request.code, | ||||
| name = request.name, | name = request.name, | ||||
| type = request.type.toString(), | type = request.type.toString(), | ||||
| message = "The item code has already existed", | |||||
| errorPosition = "code" | |||||
| message = "M18 ID already used by another item", | |||||
| errorPosition = "m18Id" | |||||
| ) | ) | ||||
| } | } | ||||
| val item = if (request.m18Id != null) findByM18Id(request.m18Id) ?: Items() | |||||
| else if (request.id != null && request.id > 0) itemsRepository.findByIdAndDeletedFalse(request.id) ?: Items() | |||||
| else Items() | |||||
| logger.info("item: $item") | |||||
| if (item.m18LastModifyDate == request.m18LastModifyDate) { | |||||
| val m18IdUnchanged = request.m18Id == null || item.m18Id == request.m18Id | |||||
| // Skip only for unchanged M18 pulls (request carries lastModifyDate). UI saves do not. | |||||
| if (request.m18LastModifyDate != null && | |||||
| item.id != null && | |||||
| item.m18LastModifyDate == request.m18LastModifyDate && | |||||
| m18IdUnchanged | |||||
| ) { | |||||
| return MessageResponse( | return MessageResponse( | ||||
| id = item.id, | id = item.id, | ||||
| code = item.code, | code = item.code, | ||||
| @@ -242,6 +242,15 @@ fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | |||||
| return bomService.getBomDetail(id) | 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 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 17 | v1.0.0 | 2026-07-16 */ | ||||
| @PostMapping("/{id}/activate-version") | @PostMapping("/{id}/activate-version") | ||||
| fun activateBomVersion(@PathVariable id: Long): BomDetailResponse { | 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> | ): List<PickOrder> | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.2 | 2026-08-25 */ | |||||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | @Modifying(clearAutomatically = true, flushAutomatically = true) | ||||
| @Query( | @Query( | ||||
| value = """ | value = """ | ||||
| @@ -169,6 +170,7 @@ fun findCompletedWithPlasticBoxCartonQtyInPlanStartRange( | |||||
| modifiedBy = :modifiedBy | modifiedBy = :modifiedBy | ||||
| WHERE deliveryOrderPickOrderId = :dopoId | WHERE deliveryOrderPickOrderId = :dopoId | ||||
| AND deleted = 0 | AND deleted = 0 | ||||
| AND LOWER(COALESCE(status, '')) <> 'completed' | |||||
| """, | """, | ||||
| nativeQuery = true | 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. | * Temporary consumable workbench exclude list until per-user DB config (Scheme A) lands. | ||||
| * | * | ||||
| * - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants]). | * - [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 { | object ConsumableWorkbenchPickConstants { | ||||
| const val HARDCODED_EXCLUDE_USER_ID: Long = 246L | 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() | 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> | 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( | @Query( | ||||
| """ | """ | ||||
| SELECT | SELECT | ||||
| p.jobOrder.id AS jobOrderId, | p.jobOrder.id AS jobOrderId, | ||||
| COUNT(l.id) AS totalLines, | 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 | FROM ProductProcessLine l | ||||
| JOIN l.productProcess p | JOIN l.productProcess p | ||||
| WHERE l.deleted = false | 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 { | open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse { | ||||
| println(" Service: Updating ProductProcessLine Status: $productProcessLineId") | println(" Service: Updating ProductProcessLine Status: $productProcessLineId") | ||||
| val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) | val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) | ||||
| @@ -1317,8 +1317,8 @@ open class ProductProcessService( | |||||
| productProcessLineRepository.save(productProcessLine) | productProcessLineRepository.save(productProcessLine) | ||||
| println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}") | 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) | autoPassSiblingPackagingLines(productProcessLineId) | ||||
| } | } | ||||
| @@ -1376,7 +1376,7 @@ open class ProductProcessService( | |||||
| val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | ||||
| println(" Service: ProductProcessLines: $productProcessLines") | println(" Service: ProductProcessLines: $productProcessLines") | ||||
| if (productProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { | |||||
| if (productProcessLines.all { isLineDone(it.status) }) { | |||||
| productProcess.status = ProductProcessStatus.COMPLETED | productProcess.status = ProductProcessStatus.COMPLETED | ||||
| if (productProcess.endTime == null) { | if (productProcess.endTime == null) { | ||||
| productProcess.endTime = LocalDateTime.now() | 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。 | * 注意: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 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 qcKeys = candidateAggregates.mapNotNull { agg -> | ||||
| val jobOrderId = agg.jobOrderId | val jobOrderId = agg.jobOrderId | ||||
| val stockInLine = stockInLineByJobOrderId[jobOrderId] | val stockInLine = stockInLineByJobOrderId[jobOrderId] | ||||
| @@ -1745,15 +1741,10 @@ bomDescription = productProcesses.bom?.bomKind, | |||||
| val lineAggregate = lineStatusByJobOrderId[jobOrderId] | val lineAggregate = lineStatusByJobOrderId[jobOrderId] | ||||
| val totalLines = lineAggregate?.totalLines ?: 0L | 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 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 | 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 joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() | ||||
| val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty() | 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 bomIsDrink = productProcess.bom?.isDrink | ||||
| val matchStatus = if (joPickOrdersList.isNotEmpty() && | 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). | * only job orders that have a product process dated **today or yesterday** (server local date). | ||||
| */ | */ | ||||
| open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse { | open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse { | ||||
| @@ -2074,7 +2062,7 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } | val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } | ||||
| val allLinesDone = jobOrderLines.isNotEmpty() && | val allLinesDone = jobOrderLines.isNotEmpty() && | ||||
| jobOrderLines.all { it.status == "Completed" || it.status == "Pass" } | |||||
| jobOrderLines.all { isLineDone(it.status) } | |||||
| if (!allLinesDone) continue | if (!allLinesDone) continue | ||||
| val maxDate = processes.mapNotNull { it.date }.maxOrNull() | val maxDate = processes.mapNotNull { it.date }.maxOrNull() | ||||
| @@ -2176,6 +2164,12 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| status?.trim()?.lowercase()?.replace(" ", "") ?: "" | status?.trim()?.lowercase()?.replace(" ", "") ?: "" | ||||
| private fun isLineDone(status: String?): Boolean { | 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) | val n = normalizeLineStatus(status) | ||||
| return n == "completed" || n == "pass" | return n == "completed" || n == "pass" | ||||
| } | } | ||||
| @@ -2185,14 +2179,14 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| return n == "inprogress" || n == "paused" | 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 = | private fun isPackagingLine(line: ProductProcessLine): Boolean = | ||||
| (line.name ?: "").trim() == PACKAGING_PROCESS_NAME | (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) { | private fun autoPassSiblingPackagingLines(triggerLineId: Long) { | ||||
| val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return | val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return | ||||
| @@ -2220,7 +2214,7 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| line.startTime = now | line.startTime = now | ||||
| } | } | ||||
| line.endTime = now | line.endTime = now | ||||
| line.status = "Pass" | |||||
| line.status = "autoPass" | |||||
| productProcessLineRepository.save(line) | productProcessLineRepository.save(line) | ||||
| line.productProcess?.id?.let { affectedProcessIds.add(it) } | line.productProcess?.id?.let { affectedProcessIds.add(it) } | ||||
| } | } | ||||
| @@ -2232,7 +2226,7 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| /** | /** | ||||
| * Align parent [ProductProcess.status] with all line states. | * 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 | * - Any line started but not all done -> in_progress | ||||
| * - Does not override STOPPED or CANCELLED on the parent. | * - Does not override STOPPED or CANCELLED on the parent. | ||||
| */ | */ | ||||
| @@ -2312,8 +2306,8 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| // 获取所有 product process lines | // 获取所有 product process lines | ||||
| val allproductProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | 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 和状态 | // 更新 product process 的 endTime 和状态 | ||||
| updateProductProcessEndTime(productProcessId) | updateProductProcessEndTime(productProcessId) | ||||
| updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) | updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) | ||||
| @@ -2828,6 +2822,7 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| } | } | ||||
| val drinkOrders = candidateJobOrders | val drinkOrders = candidateJobOrders | ||||
| .filter { it.isHidden != true } | |||||
| .filter { it.bom?.isDrink == true } | .filter { it.bom?.isDrink == true } | ||||
| .filter { jo -> | .filter { jo -> | ||||
| isPlannedView || jo.status != JobOrderStatus.PLANNING | isPlannedView || jo.status != JobOrderStatus.PLANNING | ||||
| @@ -3154,7 +3149,7 @@ bomDescription = productProcess.bom?.bomKind, | |||||
| } | } | ||||
| val currentProcesses = operatorLines | 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 -> | .map { line -> | ||||
| val productProcess = line.productProcess | val productProcess = line.productProcess | ||||
| val jobOrder = productProcess.jobOrder | 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.ProductProcess | ||||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessLine | import com.ffii.fpsms.modules.productProcess.entity.ProductProcessLine | ||||
| import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus | 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.service.ProductProcessService | ||||
| import com.ffii.fpsms.modules.productProcess.web.model.* | import com.ffii.fpsms.modules.productProcess.web.model.* | ||||
| import org.springframework.data.domain.Page | import org.springframework.data.domain.Page | ||||
| @@ -16,7 +17,8 @@ import java.time.format.DateTimeFormatter | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/product-process") | @RequestMapping("/product-process") | ||||
| class ProductProcessController( | class ProductProcessController( | ||||
| private val productProcessService: ProductProcessService | |||||
| private val productProcessService: ProductProcessService, | |||||
| private val drinkShipmentQtyService: DrinkShipmentQtyService, | |||||
| ) { | ) { | ||||
| @GetMapping | @GetMapping | ||||
| @@ -200,7 +202,7 @@ class ProductProcessController( | |||||
| return productProcessService.getAllJoborderProductProcessInfo(bomType) | 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") | @GetMapping("/Demo/Process/search") | ||||
| fun demoprocesssearch( | fun demoprocesssearch( | ||||
| @RequestParam(required = false) date: String?, | @RequestParam(required = false) date: String?, | ||||
| @@ -333,4 +335,14 @@ class ProductProcessController( | |||||
| } | } | ||||
| return productProcessService.getDrinkProductionQty(parsedDate, viewMode) | 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. | * job order has at least one process dated today or yesterday. [qc] = before received; [putAway] = received / partially_completed. | ||||
| */ | */ | ||||
| data class JobOrderFgAlertRowResponse( | data class JobOrderFgAlertRowResponse( | ||||
| @@ -400,4 +400,25 @@ data class DrinkProductionQtyResponse( | |||||
| val totalReqQty: BigDecimal, | val totalReqQty: BigDecimal, | ||||
| val totalQty: BigDecimal, | val totalQty: BigDecimal, | ||||
| val jobOrders: List<DrinkProductionQtyJobOrderDetail> = emptyList(), | 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( | open class ItemQcFailReportService( | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| ) { | ) { | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ | |||||
| fun searchItemQcFailReport( | fun searchItemQcFailReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| itemCode: String?, | itemCode: String?, | ||||
| @@ -33,16 +34,19 @@ open class ItemQcFailReportService( | |||||
| val qcItemScopeSql = buildQcItemScopeClause(measurable, other) | val qcItemScopeSql = buildQcItemScopeClause(measurable, other) | ||||
| val measuredValueSql = buildMeasuredValueClause(measurable, scope) | 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 lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { | ||||
| val formattedDate = lastInDateStart.replace("/", "-") | val formattedDate = lastInDateStart.replace("/", "-") | ||||
| args["lastInDateStart"] = formattedDate | args["lastInDateStart"] = formattedDate | ||||
| "AND DATE(sil.receiptDate) >= DATE(:lastInDateStart)" | |||||
| "AND DATE(qr.created) >= DATE(:lastInDateStart)" | |||||
| } else "" | } else "" | ||||
| val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { | val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { | ||||
| val formattedDate = lastInDateEnd.replace("/", "-") | val formattedDate = lastInDateEnd.replace("/", "-") | ||||
| args["lastInDateEnd"] = formattedDate | args["lastInDateEnd"] = formattedDate | ||||
| "AND DATE(sil.receiptDate) <= DATE(:lastInDateEnd)" | |||||
| "AND DATE(qr.created) <= DATE(:lastInDateEnd)" | |||||
| } else "" | } else "" | ||||
| val sql = """ | val sql = """ | ||||
| @@ -15,6 +15,7 @@ import java.math.BigDecimal | |||||
| class ShopOrderReplenishmentReportService( | class ShopOrderReplenishmentReportService( | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| ) { | ) { | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 49 | v1.0.1 | 2026-08-10 */ | |||||
| fun searchShopOrderReplenishmentReport( | fun searchShopOrderReplenishmentReport( | ||||
| reorderDateStart: String?, | reorderDateStart: String?, | ||||
| reorderDateEnd: String?, | reorderDateEnd: String?, | ||||
| @@ -87,6 +88,7 @@ class ShopOrderReplenishmentReportService( | |||||
| DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate, | DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate, | ||||
| dr.reason AS reason, | dr.reason AS reason, | ||||
| DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate, | DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate, | ||||
| TRIM(IFNULL(dop.handlerName, '')) AS actualDeliveredHandler, | |||||
| dr.sourceDoId AS sourceDoId, | dr.sourceDoId AS sourceDoId, | ||||
| dr.itemId AS itemId, | dr.itemId AS itemId, | ||||
| dr.pickOrderLineId AS pickOrderLineId | dr.pickOrderLineId AS pickOrderLineId | ||||
| @@ -130,7 +132,7 @@ class ShopOrderReplenishmentReportService( | |||||
| val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct() | val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct() | ||||
| val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds) | val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds) | ||||
| val firstOrderPickBySourceKey = loadFirstOrderActualPickQty(sourceDoIds, itemIds, pickOrderLineIds) | |||||
| val firstOrderPickBySourceKey = loadFirstOrderPickInfo(sourceDoIds, itemIds, pickOrderLineIds) | |||||
| val rows = baseRows.map { row -> | val rows = baseRows.map { row -> | ||||
| val polId = longVal(row["pickOrderLineId"]) | val polId = longVal(row["pickOrderLineId"]) | ||||
| @@ -138,6 +140,7 @@ class ShopOrderReplenishmentReportService( | |||||
| val itemId = longVal(row["itemId"]) | val itemId = longVal(row["itemId"]) | ||||
| val sourceKey = | val sourceKey = | ||||
| if (sourceDoId != null && itemId != null) sourceDoId to itemId else null | if (sourceDoId != null && itemId != null) sourceDoId to itemId else null | ||||
| val firstOrder = sourceKey?.let { firstOrderPickBySourceKey[it] } | |||||
| linkedMapOf<String, Any?>( | linkedMapOf<String, Any?>( | ||||
| "shopNo" to row["shopNo"], | "shopNo" to row["shopNo"], | ||||
| @@ -147,11 +150,13 @@ class ShopOrderReplenishmentReportService( | |||||
| "itemNo" to row["itemNo"], | "itemNo" to row["itemNo"], | ||||
| "itemName" to row["itemName"], | "itemName" to row["itemName"], | ||||
| "firstOrderQty" to row["firstOrderQty"], | "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"], | "reorderQty" to row["reorderQty"], | ||||
| "reorderDate" to row["reorderDate"], | "reorderDate" to row["reorderDate"], | ||||
| "reason" to row["reason"], | "reason" to row["reason"], | ||||
| "actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO), | "actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO), | ||||
| "actualDeliveredHandler" to (row["actualDeliveredHandler"] ?: ""), | |||||
| "deliveredDate" to row["deliveredDate"], | "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). | * 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>, | sourceDoIds: List<Long>, | ||||
| itemIds: List<Long>, | itemIds: List<Long>, | ||||
| excludePickOrderLineIds: List<Long>, | excludePickOrderLineIds: List<Long>, | ||||
| ): Map<Pair<Long, Long>, BigDecimal> { | |||||
| ): Map<Pair<Long, Long>, FirstOrderPickInfo> { | |||||
| if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() | if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() | ||||
| val args = mutableMapOf<String, Any>( | val args = mutableMapOf<String, Any>( | ||||
| @@ -210,7 +216,8 @@ class ShopOrderReplenishmentReportService( | |||||
| SELECT | SELECT | ||||
| po.doId AS sourceDoId, | po.doId AS sourceDoId, | ||||
| pol.itemId AS itemId, | 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 | FROM pick_order po | ||||
| INNER JOIN pick_order_line pol | INNER JOIN pick_order_line pol | ||||
| ON pol.poId = po.id | ON pol.poId = po.id | ||||
| @@ -218,6 +225,9 @@ class ShopOrderReplenishmentReportService( | |||||
| LEFT JOIN stock_out_line sol | LEFT JOIN stock_out_line sol | ||||
| ON sol.pickOrderLineId = pol.id | ON sol.pickOrderLineId = pol.id | ||||
| AND IFNULL(sol.deleted, 0) = 0 | 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 | WHERE IFNULL(po.deleted, 0) = 0 | ||||
| AND po.doId IN (:sourceDoIds) | AND po.doId IN (:sourceDoIds) | ||||
| AND pol.itemId IN (:itemIds) | AND pol.itemId IN (:itemIds) | ||||
| @@ -236,10 +246,18 @@ class ShopOrderReplenishmentReportService( | |||||
| return rows.mapNotNull { row -> | return rows.mapNotNull { row -> | ||||
| val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null | val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null | ||||
| val itemId = longVal(row["itemId"]) ?: 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() | }.toMap() | ||||
| } | } | ||||
| private data class FirstOrderPickInfo( | |||||
| val qty: BigDecimal, | |||||
| val handler: String, | |||||
| ) | |||||
| private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-") | private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-") | ||||
| private fun dateStartClause( | private fun dateStartClause( | ||||
| @@ -4,17 +4,25 @@ import com.ffii.core.support.JdbcDao | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| @Service | @Service | ||||
| open class StockLedgerReportService( | open class StockLedgerReportService( | ||||
| private val jdbcDao: JdbcDao, | 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 報表查詢 | * Stock Ledger 報表查詢 | ||||
| * | * | ||||
| * - stockSubCategory = items.type | * - stockSubCategory = items.type | ||||
| * - trnDate = stock_ledger.date | * - trnDate = stock_ledger.date | ||||
| * - trnRefNo = stock_ledger.type | * - trnRefNo = stock_ledger.type | ||||
| * - cumBalance = stock_ledger.balance(異動後纍計存量) | |||||
| * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) | |||||
| * | |||||
| * 只查 [start, end] 期間列,不掃起日以前全歷史。 | |||||
| */ | */ | ||||
| fun searchStockLedgerReport( | fun searchStockLedgerReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| @@ -23,18 +31,15 @@ open class StockLedgerReportService( | |||||
| reportPeriodStart: String?, | reportPeriodStart: String?, | ||||
| reportPeriodEnd: String?, | reportPeriodEnd: String?, | ||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| // 1) 先決定 reportPeriodEnd:如果有填 end,就用使用者的;否則用今天 | |||||
| val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") | val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") | ||||
| ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) | |||||
| // 2) 如果有填 start,就用使用者的;否則從 DB 查最早一筆日期 | |||||
| ?: LocalDate.now().format(dateFmt)) | |||||
| val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { | val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { | ||||
| reportPeriodStart.replace("/", "-") | reportPeriodStart.replace("/", "-") | ||||
| } else { | } else { | ||||
| // 用簡單 SQL 查全表最早一筆日期(可加上類似 stockCategory/itemCode 過濾) | |||||
| val minDateSql = """ | val minDateSql = """ | ||||
| SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate | SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate | ||||
| FROM stock_ledger sl | FROM stock_ledger sl | ||||
| @@ -42,188 +47,39 @@ open class StockLedgerReportService( | |||||
| AND sl.itemCode IS NOT NULL | AND sl.itemCode IS NOT NULL | ||||
| AND sl.itemCode <> '' | AND sl.itemCode <> '' | ||||
| """.trimIndent() | """.trimIndent() | ||||
| val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull() | val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull() | ||||
| (minDateRow?.get("firstDate") as? String) | (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["reportPeriodStart"] = reportPeriodStart | ||||
| args["reportPeriodEnd"] = reportPeriodEnd | |||||
| // 4) 之後再用你原來的 stockCategorySql / itemCodeSql / storeLocationSql | |||||
| args["reportPeriodEndExclusive"] = endExclusive | |||||
| val stockCategorySql = buildMultiValueExactClause( | val stockCategorySql = buildMultiValueExactClause( | ||||
| stockCategory, | stockCategory, | ||||
| "it.type", | "it.type", | ||||
| "stockCategory", | "stockCategory", | ||||
| args | args | ||||
| ) | ) | ||||
| val itemCodeSql = buildMultiValueLikeClause( | val itemCodeSql = buildMultiValueLikeClause( | ||||
| itemCode, | itemCode, | ||||
| "sl.itemCode", | "sl.itemCode", | ||||
| "itemCode", | "itemCode", | ||||
| args | args | ||||
| ) | ) | ||||
| // 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 | |||||
| val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | ||||
| args["storeLocation"] = "%$storeLocation%" | args["storeLocation"] = "%$storeLocation%" | ||||
| "AND (wh_in.code LIKE :storeLocation OR wh_out.code LIKE :storeLocation)" | |||||
| "AND lot.storeLocation LIKE :storeLocation" | |||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| val reportPeriodEndSql = "AND DATE(sl.date) <= :reportPeriodEnd" | |||||
| val sql = """ | 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 | SELECT | ||||
| stockSubCategory, | stockSubCategory, | ||||
| itemNo, | itemNo, | ||||
| @@ -231,7 +87,7 @@ SELECT | |||||
| unitOfMeasure, | unitOfMeasure, | ||||
| lotNo, | lotNo, | ||||
| expiryDate, | expiryDate, | ||||
| trnDate, | |||||
| trnDate, | |||||
| CASE trnRefNoRaw | CASE trnRefNoRaw | ||||
| WHEN 'OPEN' THEN '開倉' | WHEN 'OPEN' THEN '開倉' | ||||
| WHEN 'NOR' THEN '出入倉' | WHEN 'NOR' THEN '出入倉' | ||||
| @@ -255,39 +111,171 @@ SELECT | |||||
| reOrderLevel, | reOrderLevel, | ||||
| reOrderQty, | 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 | ORDER BY | ||||
| itemNo, | itemNo, | ||||
| trnDateRaw, | trnDateRaw, | ||||
| slId, | slId, | ||||
| lotNo | lotNo | ||||
| """.trimIndent() | """.trimIndent() | ||||
| val result = jdbcDao.queryForList(sql, args) | |||||
| return result | |||||
| return jdbcDao.queryForList(sql, args) | |||||
| } | } | ||||
| /** LIKE 多值工具方法 */ | /** LIKE 多值工具方法 */ | ||||
| @@ -327,4 +315,4 @@ ORDER BY | |||||
| } | } | ||||
| return "AND (${conditions.joinToString(" OR ")})" | 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, | private val itemQcFailReportService: ItemQcFailReportService, | ||||
| ) { | ) { | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ | |||||
| @GetMapping("/print-item-qc-fail") | @GetMapping("/print-item-qc-fail") | ||||
| fun generateItemQcFailReport( | fun generateItemQcFailReport( | ||||
| @RequestParam(required = false) stockCategory: String?, | @RequestParam(required = false) stockCategory: String?, | ||||
| @@ -29,7 +30,7 @@ class ItemQcFailReportController( | |||||
| @RequestParam(required = false) lastInDateEnd: String?, | @RequestParam(required = false) lastInDateEnd: String?, | ||||
| @RequestParam(required = false) qcType: String?, | @RequestParam(required = false) qcType: String?, | ||||
| @RequestParam(required = false, defaultValue = "true") includeMeasurable: 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?, | @RequestParam(required = false, defaultValue = "all") measurableScope: String?, | ||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val dbData = itemQcFailReportService.searchItemQcFailReport( | val dbData = itemQcFailReportService.searchItemQcFailReport( | ||||
| @@ -70,6 +71,7 @@ class ItemQcFailReportController( | |||||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | 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") | @GetMapping("/print-item-qc-fail-excel") | ||||
| fun exportItemQcFailReportExcel( | fun exportItemQcFailReportExcel( | ||||
| @RequestParam(required = false) stockCategory: String?, | @RequestParam(required = false) stockCategory: String?, | ||||
| @@ -78,7 +80,7 @@ class ItemQcFailReportController( | |||||
| @RequestParam(required = false) lastInDateEnd: String?, | @RequestParam(required = false) lastInDateEnd: String?, | ||||
| @RequestParam(required = false) qcType: String?, | @RequestParam(required = false) qcType: String?, | ||||
| @RequestParam(required = false, defaultValue = "true") includeMeasurable: 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?, | @RequestParam(required = false, defaultValue = "all") measurableScope: String?, | ||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val dbData = itemQcFailReportService.searchItemQcFailReport( | val dbData = itemQcFailReportService.searchItemQcFailReport( | ||||
| @@ -297,7 +299,7 @@ class ItemQcFailReportController( | |||||
| "不合格數量", | "不合格數量", | ||||
| "實測值", | "實測值", | ||||
| "備註", | "備註", | ||||
| "訂單/工單" | |||||
| "訂單/工單", | |||||
| ) | ) | ||||
| run { | run { | ||||
| @@ -321,12 +323,16 @@ class ItemQcFailReportController( | |||||
| fun writeNumber(col: Int, value: Any?) { | fun writeNumber(col: Int, value: Any?) { | ||||
| val raw = value?.toString()?.trim() ?: "" | 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 bd = cleaned.toBigDecimalOrNull() | ||||
| val cell = row.createCell(col) | val cell = row.createCell(col) | ||||
| if (bd == null) { | if (bd == null) { | ||||
| cell.setCellValue(cleaned) | |||||
| cell.setCellValue(raw) | |||||
| cell.cellStyle = textStyle | cell.cellStyle = textStyle | ||||
| } else { | } else { | ||||
| val stripped = bd.stripTrailingZeros() | 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.IndexedColors | ||||
| import org.apache.poi.ss.usermodel.Row | import org.apache.poi.ss.usermodel.Row | ||||
| import org.apache.poi.ss.usermodel.VerticalAlignment | 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.CellRangeAddress | ||||
| import org.apache.poi.ss.util.WorkbookUtil | 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 | import java.io.ByteArrayOutputStream | ||||
| @RestController | @RestController | ||||
| @@ -27,66 +27,66 @@ class StockLedgerReportController( | |||||
| private val stockLedgerReportService: StockLedgerReportService, | private val stockLedgerReportService: StockLedgerReportService, | ||||
| ) { | ) { | ||||
| private data class ExcelStyles( | 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") | @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") | @GetMapping("/print-stock-ledger-excel") | ||||
| fun exportStockLedgerReportExcel( | fun exportStockLedgerReportExcel( | ||||
| @RequestParam(required = false) stockCategory: String?, | @RequestParam(required = false) stockCategory: String?, | ||||
| @@ -119,8 +119,8 @@ fun generateStockLedgerReport( | |||||
| return ResponseEntity(excelBytes, headers, HttpStatus.OK) | 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 | alignment = HorizontalAlignment.CENTER | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val font = workbook.createFont().apply { | val font = workbook.createFont().apply { | ||||
| @@ -129,7 +129,7 @@ fun generateStockLedgerReport( | |||||
| } | } | ||||
| setFont(font) | setFont(font) | ||||
| } | } | ||||
| val subtitleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val subtitleStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.LEFT | alignment = HorizontalAlignment.LEFT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val font = workbook.createFont().apply { | val font = workbook.createFont().apply { | ||||
| @@ -138,7 +138,7 @@ fun generateStockLedgerReport( | |||||
| } | } | ||||
| setFont(font) | setFont(font) | ||||
| } | } | ||||
| val headerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val headerStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.CENTER | alignment = HorizontalAlignment.CENTER | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | ||||
| @@ -150,7 +150,7 @@ fun generateStockLedgerReport( | |||||
| val font = workbook.createFont().apply { bold = true } | val font = workbook.createFont().apply { bold = true } | ||||
| setFont(font) | setFont(font) | ||||
| } | } | ||||
| val textStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val textStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.LEFT | alignment = HorizontalAlignment.LEFT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THIN | borderTop = BorderStyle.THIN | ||||
| @@ -158,7 +158,7 @@ fun generateStockLedgerReport( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| } | } | ||||
| val centerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val centerStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.CENTER | alignment = HorizontalAlignment.CENTER | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THIN | borderTop = BorderStyle.THIN | ||||
| @@ -166,7 +166,7 @@ fun generateStockLedgerReport( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| } | } | ||||
| val intStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val intStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THIN | borderTop = BorderStyle.THIN | ||||
| @@ -176,7 +176,7 @@ fun generateStockLedgerReport( | |||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | dataFormat = df.getFormat("#,##0") | ||||
| } | } | ||||
| val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val dashStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THIN | borderTop = BorderStyle.THIN | ||||
| @@ -184,7 +184,7 @@ fun generateStockLedgerReport( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| } | } | ||||
| val sumQty = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val sumQty = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| @@ -196,7 +196,7 @@ fun generateStockLedgerReport( | |||||
| val font = workbook.createFont().apply { bold = true } | val font = workbook.createFont().apply { bold = true } | ||||
| setFont(font) | setFont(font) | ||||
| } | } | ||||
| val sumLabel = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val sumLabel = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THICK | borderTop = BorderStyle.THICK | ||||
| @@ -206,7 +206,7 @@ fun generateStockLedgerReport( | |||||
| val font = workbook.createFont().apply { bold = true } | val font = workbook.createFont().apply { bold = true } | ||||
| setFont(font) | setFont(font) | ||||
| } | } | ||||
| val sumEmpty = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val sumEmpty = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.LEFT | alignment = HorizontalAlignment.LEFT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THICK | borderTop = BorderStyle.THICK | ||||
| @@ -214,7 +214,7 @@ fun generateStockLedgerReport( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| } | } | ||||
| val sumHidden = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val sumHidden = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.LEFT | alignment = HorizontalAlignment.LEFT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THICK | 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 { | row.createCell(col).apply { | ||||
| setCellValue(value?.toString() ?: "") | setCellValue(value?.toString() ?: "") | ||||
| cellStyle = style | cellStyle = style | ||||
| @@ -259,8 +259,8 @@ fun generateStockLedgerReport( | |||||
| row: Row, | row: Row, | ||||
| col: Int, | col: Int, | ||||
| value: Any?, | value: Any?, | ||||
| intStyle: XSSFCellStyle, | |||||
| dashStyle: XSSFCellStyle, | |||||
| intStyle: CellStyle, | |||||
| dashStyle: CellStyle, | |||||
| ) { | ) { | ||||
| val cell = row.createCell(col) | val cell = row.createCell(col) | ||||
| val parsed = parseSignedNumber(value) | 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( | private fun createStockLedgerExcel( | ||||
| dbData: List<Map<String, Any>>, | dbData: List<Map<String, Any>>, | ||||
| reportPeriodStart: String, | reportPeriodStart: String, | ||||
| reportPeriodEnd: String, | reportPeriodEnd: String, | ||||
| ): ByteArray { | ): 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++) | 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> { | open fun checkAndUpdateStockTakeStatus(stockTakeId: Long, stockTakeSection: String): Map<String, Any> { | ||||
| try { | try { | ||||
| val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId) | 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() && | val allRecordsPassed = stockTakeRecords.isNotEmpty() && | ||||
| stockTakeRecords.all { it.status == "pass" || it.status == "completed" } | stockTakeRecords.all { it.status == "pass" || it.status == "completed" } | ||||
| val allRecordsCompleted = stockTakeRecords.isNotEmpty() && | val allRecordsCompleted = stockTakeRecords.isNotEmpty() && | ||||
| stockTakeRecords.all { it.status == "completed" } | 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.status = StockTakeStatus.COMPLETED | ||||
| stockTake.planEnd = java.time.LocalDateTime.now() | |||||
| stockTake.planEnd = now | |||||
| stockTake.actualEnd = now | |||||
| stockTakeRepository.save(stockTake) | stockTakeRepository.save(stockTake) | ||||
| println("Stock take $stockTakeId status updated to COMPLETED - all records are completed") | println("Stock take $stockTakeId status updated to COMPLETED - all records are completed") | ||||
| return mapOf( | return mapOf( | ||||
| @@ -1667,10 +1669,9 @@ open class StockTakeRecordService( | |||||
| "message" to "Stock take status updated to COMPLETED", | "message" to "Stock take status updated to COMPLETED", | ||||
| "updated" to true | "updated" to true | ||||
| ) | ) | ||||
| } else if (allLinesHaveRecords && allRecordsPassed) { | |||||
| // 如果所有记录都已创建且都是 "pass" 或 "completed",更新 stock take 状态为 "approving" | |||||
| } else if (allRecordsPassed) { | |||||
| stockTake.status = StockTakeStatus.APPROVING | stockTake.status = StockTakeStatus.APPROVING | ||||
| stockTake.actualEnd = java.time.LocalDateTime.now() | |||||
| stockTake.actualEnd = now | |||||
| stockTakeRepository.save(stockTake) | stockTakeRepository.save(stockTake) | ||||
| println("Stock take $stockTakeId status updated to APPROVING - all records are pass") | println("Stock take $stockTakeId status updated to APPROVING - all records are pass") | ||||
| @@ -1684,7 +1685,6 @@ open class StockTakeRecordService( | |||||
| "success" to true, | "success" to true, | ||||
| "message" to "Conditions not met for status update", | "message" to "Conditions not met for status update", | ||||
| "updated" to false, | "updated" to false, | ||||
| "allLinesHaveRecords" to allLinesHaveRecords, | |||||
| "allRecordsPassed" to allRecordsPassed, | "allRecordsPassed" to allRecordsPassed, | ||||
| "allRecordsCompleted" to allRecordsCompleted | "allRecordsCompleted" to allRecordsCompleted | ||||
| ) | ) | ||||
| @@ -13,6 +13,8 @@ import com.ffii.fpsms.modules.user.entity.projections.UserCombo; | |||||
| public interface UserRepository extends AbstractRepository<User, Long> { | public interface UserRepository extends AbstractRepository<User, Long> { | ||||
| List<User> findByName(@Param("name") String name); | List<User> findByName(@Param("name") String name); | ||||
| List<User> findByNameAndDeletedFalse(String name); | |||||
| Optional<User> findByUsernameAndDeletedFalse(String username); | 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> findByStaffNo(@Param("staffNo") String staffNo); | ||||
| Optional<User> findByStaffNoAndDeletedFalse(String staffNo); | |||||
| @Modifying | @Modifying | ||||
| @Query(value = """ | @Query(value = """ | ||||
| INSERT INTO user_authority (userID, authId) | INSERT INTO user_authority (userID, authId) | ||||
| @@ -92,6 +92,22 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||||
| return userRepository.findByUsernameAndDeletedFalse(username); | 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) | // @Transactional(rollbackFor = Exception.class) | ||||
| public List<UserRecord> search(SearchUserReq req) { | public List<UserRecord> search(SearchUserReq req) { | ||||
| StringBuilder sql = new StringBuilder("SELECT" | StringBuilder sql = new StringBuilder("SELECT" | ||||
| @@ -199,11 +215,18 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||||
| return instance; | return instance; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */ | |||||
| @Transactional(rollbackFor = Exception.class) | @Transactional(rollbackFor = Exception.class) | ||||
| public User newRecord(NewUserReq req) throws UnsupportedEncodingException { | public User newRecord(NewUserReq req) throws UnsupportedEncodingException { | ||||
| if (findByUsername(req.getUsername()).isPresent()) { | if (findByUsername(req.getUsername()).isPresent()) { | ||||
| throw new UnprocessableEntityException(ErrorCodes.USERNAME_NOT_AVAILABLE); | 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"); | 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.entity.JobOrderRepository | ||||
| import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService | import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService | ||||
| import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService | |||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | import com.ffii.fpsms.modules.master.service.ItemUomService | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import org.springframework.format.annotation.DateTimeFormat | import org.springframework.format.annotation.DateTimeFormat | ||||
| @@ -28,6 +29,7 @@ open class PyController( | |||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val plasticBagPrinterService: PlasticBagPrinterService, | private val plasticBagPrinterService: PlasticBagPrinterService, | ||||
| private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, | ||||
| private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, | |||||
| ) { | ) { | ||||
| companion object { | companion object { | ||||
| private const val PACKAGING_PROCESS_NAME = "包裝" | private const val PACKAGING_PROCESS_NAME = "包裝" | ||||
| @@ -53,8 +55,22 @@ open class PyController( | |||||
| ) | ) | ||||
| val ids = orders.mapNotNull { it.id } | val ids = orders.mapNotNull { it.id } | ||||
| val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | ||||
| val printDate = ItemDefaultShelfLifeService.today() | |||||
| val shelfLifeByCode = itemDefaultShelfLifeService.printShelfLifeByItemCodes( | |||||
| orders.map { it.bom?.item?.code ?: it.bom?.code }, | |||||
| ) | |||||
| val list = orders.map { jo -> | val list = orders.map { jo -> | ||||
| PyJobOrderListMapper.toListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) | |||||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | |||||
| val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(itemCode, shelfLifeByCode, printDate) | |||||
| PyJobOrderListMapper.toListItem( | |||||
| jo, | |||||
| printed[jo.id!!], | |||||
| stockInLineRepository, | |||||
| itemUomService, | |||||
| defaultShelfLifeDays = days, | |||||
| useMinus18 = useMinus18, | |||||
| expiryDate = expiry, | |||||
| ) | |||||
| } | } | ||||
| return ResponseEntity.ok(list) | return ResponseEntity.ok(list) | ||||
| } | } | ||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.py | package com.ffii.fpsms.py | ||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import java.time.LocalDate | |||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| /** | /** | ||||
| @@ -27,4 +28,10 @@ data class PyJobOrderListItem( | |||||
| val labelPrintedQty: Long = 0, | val labelPrintedQty: Long = 0, | ||||
| /** Cumulative qty from 激光機 submits (LASER). */ | /** Cumulative qty from 激光機 submits (LASER). */ | ||||
| val laserPrintedQty: Long = 0, | val laserPrintedQty: Long = 0, | ||||
| /** Effective shelf life days used for print (chilled or -18, according to useMinus18). */ | |||||
| val defaultShelfLifeDays: Int? = null, | |||||
| /** True when expiry is computed from minus18Days instead of chilled defaultDays. */ | |||||
| val useMinus18: Boolean? = null, | |||||
| /** Print date (today, Asia/Hong_Kong) + effective shelf life days. */ | |||||
| val expiryDate: LocalDate? = null, | |||||
| ) | ) | ||||
| @@ -1,8 +1,11 @@ | |||||
| package com.ffii.fpsms.py | package com.ffii.fpsms.py | ||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrder | import com.ffii.fpsms.modules.jobOrder.entity.JobOrder | ||||
| import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService | |||||
| import com.ffii.fpsms.modules.master.service.ItemPrintShelfLife | |||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | import com.ffii.fpsms.modules.master.service.ItemUomService | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import java.time.LocalDate | |||||
| object PyJobOrderListMapper { | object PyJobOrderListMapper { | ||||
| @@ -80,6 +83,9 @@ object PyJobOrderListMapper { | |||||
| printed: PrintedQtyByChannel?, | printed: PrintedQtyByChannel?, | ||||
| stockInLineRepository: StockInLineRepository, | stockInLineRepository: StockInLineRepository, | ||||
| itemUomService: ItemUomService, | itemUomService: ItemUomService, | ||||
| defaultShelfLifeDays: Int? = null, | |||||
| useMinus18: Boolean? = null, | |||||
| expiryDate: LocalDate? = null, | |||||
| ): PyJobOrderListItem { | ): PyJobOrderListItem { | ||||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | val itemCode = jo.bom?.item?.code ?: jo.bom?.code | ||||
| val baseName = jo.bom?.name ?: jo.bom?.item?.name | val baseName = jo.bom?.name ?: jo.bom?.item?.name | ||||
| @@ -107,6 +113,9 @@ object PyJobOrderListMapper { | |||||
| bagPrintedQty = p.bagPrintedQty, | bagPrintedQty = p.bagPrintedQty, | ||||
| labelPrintedQty = p.labelPrintedQty, | labelPrintedQty = p.labelPrintedQty, | ||||
| laserPrintedQty = p.laserPrintedQty, | laserPrintedQty = p.laserPrintedQty, | ||||
| defaultShelfLifeDays = defaultShelfLifeDays, | |||||
| useMinus18 = useMinus18, | |||||
| expiryDate = expiryDate, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -116,6 +125,9 @@ object PyJobOrderListMapper { | |||||
| printed: PrintedQtyByChannel?, | printed: PrintedQtyByChannel?, | ||||
| stockInLineRepository: StockInLineRepository, | stockInLineRepository: StockInLineRepository, | ||||
| itemUomService: ItemUomService, | itemUomService: ItemUomService, | ||||
| defaultShelfLifeDays: Int? = null, | |||||
| useMinus18: Boolean? = null, | |||||
| expiryDate: LocalDate? = null, | |||||
| ): PyJobOrderListItem { | ): PyJobOrderListItem { | ||||
| val itemCode = jo.bom?.item?.code ?: jo.bom?.code | val itemCode = jo.bom?.item?.code ?: jo.bom?.code | ||||
| val baseName = jo.bom?.name ?: jo.bom?.item?.name | val baseName = jo.bom?.name ?: jo.bom?.item?.name | ||||
| @@ -143,6 +155,20 @@ object PyJobOrderListMapper { | |||||
| bagPrintedQty = p.bagPrintedQty, | bagPrintedQty = p.bagPrintedQty, | ||||
| labelPrintedQty = p.labelPrintedQty, | labelPrintedQty = p.labelPrintedQty, | ||||
| laserPrintedQty = p.laserPrintedQty, | laserPrintedQty = p.laserPrintedQty, | ||||
| defaultShelfLifeDays = defaultShelfLifeDays, | |||||
| useMinus18 = useMinus18, | |||||
| expiryDate = expiryDate, | |||||
| ) | ) | ||||
| } | } | ||||
| fun shelfLifeForItem( | |||||
| itemCode: String?, | |||||
| byCode: Map<String, ItemPrintShelfLife>, | |||||
| printDate: LocalDate = ItemDefaultShelfLifeService.today(), | |||||
| ): Triple<Int?, Boolean?, LocalDate?> { | |||||
| val info = byCode[itemCode?.trim()?.uppercase().orEmpty()] | |||||
| val days = info?.effectiveDays | |||||
| val expiry = days?.let { ItemDefaultShelfLifeService.expiryOn(printDate, it) } | |||||
| return Triple(days, info?.useMinus18, expiry) | |||||
| } | |||||
| } | } | ||||
| @@ -0,0 +1,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"> | <band height="18"> | ||||
| <textField textAdjust="StretchHeight"> | <textField textAdjust="StretchHeight"> | ||||
| <reportElement isPrintRepeatedValues="false" x="11" y="0" width="579" height="18" uuid="5b2d2e95-26eb-4e8c-93ba-99eaed3490df"/> | <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"/> | <font fontName="微軟正黑體" isBold="true"/> | ||||
| </textElement> | </textElement> | ||||
| <textFieldExpression><![CDATA[$F{itemNo}+" "+$F{itemName}+" "+$F{unitOfMeasure}]]></textFieldExpression> | <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.assertFalse | ||||
| import org.junit.jupiter.api.Assertions.assertTrue | import org.junit.jupiter.api.Assertions.assertTrue | ||||
| import org.junit.jupiter.api.Test | import org.junit.jupiter.api.Test | ||||
| import java.time.LocalDate | |||||
| import com.ffii.fpsms.modules.master.service.ItemPrintShelfLife | |||||
| class PyJobOrderListMapperTest { | class PyJobOrderListMapperTest { | ||||
| @@ -64,14 +66,38 @@ class PyJobOrderListMapperTest { | |||||
| } | } | ||||
| @Test | @Test | ||||
| fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() { | |||||
| assertEquals( | |||||
| "鮮檸檬汁(P+4)", | |||||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"), | |||||
| ) | |||||
| assertEquals( | |||||
| "咖哩汁(1包X2磅)", | |||||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"), | |||||
| ) | |||||
| } | |||||
| fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() { | |||||
| assertEquals( | |||||
| "鮮檸檬汁(P+4)", | |||||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"), | |||||
| ) | |||||
| assertEquals( | |||||
| "咖哩汁(1包X2磅)", | |||||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"), | |||||
| ) | |||||
| } | |||||
| @Test | |||||
| fun shelfLifeForItem_computes_expiry_from_print_date() { | |||||
| val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem( | |||||
| "f0025", | |||||
| mapOf("F0025" to ItemPrintShelfLife(365, false)), | |||||
| LocalDate.of(2026, 8, 20), | |||||
| ) | |||||
| assertEquals(365, days) | |||||
| assertEquals(false, useMinus18) | |||||
| assertEquals(LocalDate.of(2027, 8, 20), expiry) | |||||
| } | |||||
| @Test | |||||
| fun shelfLifeForItem_is_null_when_item_missing() { | |||||
| val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem( | |||||
| "PP1175", | |||||
| emptyMap(), | |||||
| LocalDate.of(2026, 8, 20), | |||||
| ) | |||||
| assertEquals(null, days) | |||||
| assertEquals(null, useMinus18) | |||||
| assertEquals(null, expiry) | |||||
| } | |||||
| } | } | ||||