Parcourir la source

Merge branch 'production' into bomUpdateTest

# Conflicts:
#	src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
bomUpdateTest
CANCERYS\kw093 il y a 2 semaines
Parent
révision
4c9cd13ff8
85 fichiers modifiés avec 9353 ajouts et 628 suppressions
  1. +4
    -0
      build.gradle
  2. +85
    -0
      docs/deploy/20260819_po-m18id-remap.md
  3. +3148
    -0
      python/Bag4.py
  4. +38
    -0
      python/Bag4.spec
  5. BIN
      python/__pycache__/Bag4.cpython-314.pyc
  6. +17
    -11
      python/installAndExe.txt
  7. +186
    -0
      scripts/generate_item_default_shelf_life_liquibase.py
  8. +1
    -1
      src/main/java/com/ffii/fpsms/config/WebConfig.java
  9. +10
    -0
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  10. +28
    -16
      src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt
  11. +57
    -22
      src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt
  12. +6
    -2
      src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt
  13. +2
    -0
      src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java
  14. +17
    -5
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  15. +1
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt
  16. +41
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt
  17. +29
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt
  18. +1
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt
  19. +1
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt
  20. +45
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt
  21. +152
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt
  22. +61
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt
  23. +34
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt
  24. +577
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt
  25. +13
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt
  26. +454
    -85
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  27. +92
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt
  28. +102
    -20
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt
  29. +33
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt
  30. +13
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt
  31. +53
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt
  32. +2
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt
  33. +43
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt
  34. +18
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt
  35. +1
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt
  36. +321
    -2
      src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
  37. +242
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt
  38. +33
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt
  39. +55
    -18
      src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
  40. +9
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt
  41. +64
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt
  42. +2
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt
  43. +6
    -3
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt
  44. +2
    -1
      src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt
  45. +400
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/service/DrinkShipmentQtyService.kt
  46. +29
    -34
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  47. +14
    -2
      src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt
  48. +22
    -1
      src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt
  49. +128
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt
  50. +6
    -2
      src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt
  51. +25
    -7
      src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt
  52. +182
    -194
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt
  53. +419
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt
  54. +116
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt
  55. +11
    -5
      src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt
  56. +201
    -172
      src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt
  57. +321
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt
  58. +11
    -11
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  59. +4
    -0
      src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java
  60. +23
    -0
      src/main/java/com/ffii/fpsms/modules/user/service/UserService.java
  61. +17
    -1
      src/main/java/com/ffii/fpsms/py/PyController.kt
  62. +7
    -0
      src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt
  63. +26
    -0
      src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt
  64. +8
    -0
      src/main/resources/db/changelog/changes/20260813_m18_sync_authority/01_add_m18_sync_authority.sql
  65. +21
    -0
      src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql
  66. +17
    -0
      src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql
  67. +181
    -0
      src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql
  68. +8
    -0
      src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql
  69. +220
    -0
      src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql
  70. +21
    -0
      src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql
  71. +18
    -0
      src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql
  72. +44
    -0
      src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql
  73. +6
    -0
      src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql
  74. BIN
      src/main/resources/excelTemplate/bom_import_blank.xlsx
  75. +1
    -1
      src/main/resources/jasper/StockLedgarReport.jrxml
  76. BIN
      src/main/resources/onpack2030_exp/pp1181.image
  77. +66
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt
  78. +109
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt
  79. +40
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt
  80. +42
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt
  81. +87
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt
  82. +206
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt
  83. +67
    -0
      src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt
  84. +94
    -0
      src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt
  85. +36
    -10
      src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt

+ 4
- 0
build.gradle Voir le fichier

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

test {
useJUnitPlatform()
}

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


+ 85
- 0
docs/deploy/20260819_po-m18id-remap.md Voir le fichier

@@ -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.

+ 3148
- 0
python/Bag4.py
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 38
- 0
python/Bag4.spec Voir le fichier

@@ -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,
)

BIN
python/__pycache__/Bag4.cpython-314.pyc Voir le fichier


+ 17
- 11
python/installAndExe.txt Voir le fichier

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

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

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

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

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

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

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

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

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

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

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

+ 186
- 0
scripts/generate_item_default_shelf_life_liquibase.py Voir le fichier

@@ -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()

+ 1
- 1
src/main/java/com/ffii/fpsms/config/WebConfig.java Voir le fichier

@@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer {
registry.addMapping("/**")
.allowedHeaders("*")
.allowedOrigins("*")
.exposedHeaders("filename", "Content-Disposition")
.exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry")
.allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS");

}


+ 10
- 0
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java Voir le fichier

@@ -75,6 +75,7 @@ public class SecurityConfig {
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13
* FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06
* FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05
* (stockAdjustment/submit → INVENTORY_ADJUST only)
@@ -128,6 +129,15 @@ public class SecurityConfig {
/* 工序「已完成」(Just Pass):僅 ADMIN */
.requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**")
.hasAuthority("ADMIN")
/* M18 手動同步頁:ADMIN 或 M18_SYNC。po-by-code 另允許 PURCHASE(採購單搜尋自動同步)。 */
.requestMatchers(HttpMethod.GET, "/m18/test/po-by-code")
.hasAnyAuthority("ADMIN", "M18_SYNC", "PURCHASE")
.requestMatchers(HttpMethod.GET, "/m18/test/do-by-code")
.hasAnyAuthority("ADMIN", "M18_SYNC")
.requestMatchers(HttpMethod.GET, "/m18/test/do-by-code-extra")
.hasAnyAuthority("ADMIN", "M18_SYNC")
.requestMatchers(HttpMethod.GET, "/m18/test/product-by-code")
.hasAnyAuthority("ADMIN", "M18_SYNC")
.anyRequest().authenticated())
.httpBasic(httpBasic -> httpBasic.authenticationEntryPoint(
(request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED")))


+ 28
- 16
src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt Voir le fichier

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

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

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

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

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

val existingItemUoms = itemUomService.findAllByItemsId(localItemId)


+ 57
- 22
src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt Voir le fichier

@@ -717,9 +717,13 @@ open class ChartService(
}

/**
* Staff delivery performance: daily pick ticket count and total time per staff.
* FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10
* Staff delivery performance: daily pick ticket count, duration, item kinds, and actual picked qty.
* Uses delivery_order_pick_order (handler = handledBy); time = sum of
* (ticketCompleteDateTime - ticketReleaseTime) per completed ticket.
* itemKindCount = sum of per-ticket COUNT(DISTINCT pol.itemId);
* itemQtyPicked = sum of stock_out_line.qty via pick_order_line (actual picked, not pol.qty).
* Scoped CTE first (date/store/staff) then lineAgg — avoids full-history pick/sol scan.
* staffNos: when non-empty, filter to these staff by user.staffNo (multi-select).
* storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId).
* storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed).
@@ -742,12 +746,13 @@ open class ChartService(
args["endExclusive"] = endDate.plusDays(1).atStartOfDay()
"AND dop.ticketCompleteDateTime < :endExclusive"
} else ""
val staffSql = if (!staffNos.isNullOrEmpty()) {
val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() }
if (nos.isEmpty()) "" else {
args["staffNos"] = nos
"AND u.staffNo IN (:staffNos)"
}
val staffNosFiltered = staffNos?.map { it.trim() }?.filter { it.isNotBlank() }.orEmpty()
val staffSql = if (staffNosFiltered.isNotEmpty()) {
args["staffNos"] = staffNosFiltered
"AND u_scope.staffNo IN (:staffNos)"
} else ""
val scopeUserJoin = if (staffSql.isNotEmpty()) {
"LEFT JOIN user u_scope ON dop.handledBy = u_scope.id AND u_scope.deleted = 0"
} else ""
val storeSql = when {
storeIdNull == true -> "AND dop.storeId IS NULL"
@@ -758,31 +763,61 @@ open class ChartService(
else -> ""
}
val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank()
val fromClause = if (useStoreFilter) {
val dopFromClause = if (useStoreFilter) {
"FROM delivery_order_pick_order dop"
} else {
"FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)"
}
val sql = """
WITH dop_scoped AS (
SELECT
dop.id,
dop.ticketCompleteDateTime,
dop.ticketReleaseTime,
dop.handledBy,
dop.handlerName
$dopFromClause
$scopeUserJoin
WHERE dop.deleted = 0
AND dop.ticketStatus = 'completed'
AND dop.ticketCompleteDateTime IS NOT NULL
$startSql $endSql $storeSql $staffSql
),
lineAgg AS (
SELECT
po.deliveryOrderPickOrderId AS dopId,
COUNT(DISTINCT pol.itemId) AS itemKindCount,
COALESCE(SUM(sol.qty), 0) AS itemQtyPicked
FROM dop_scoped d
INNER JOIN pick_order po
ON po.deliveryOrderPickOrderId = d.id
AND po.deleted = 0
INNER JOIN pick_order_line pol
ON pol.poId = po.id
AND pol.deleted = 0
LEFT JOIN stock_out_line sol FORCE INDEX (idx_sol_polid_deleted_status_qty)
ON sol.pickOrderLineId = pol.id
AND sol.deleted = 0
GROUP BY po.deliveryOrderPickOrderId
)
SELECT
DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date,
COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName,
COUNT(dop.id) AS orderCount,
DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d') AS date,
COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), d.handlerName, 'Unknown') AS staffName,
COUNT(d.id) AS orderCount,
COALESCE(SUM(
CASE
WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL
THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime))
WHEN d.ticketReleaseTime IS NOT NULL AND d.ticketCompleteDateTime IS NOT NULL
THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, d.ticketReleaseTime, d.ticketCompleteDateTime))
ELSE 0
END
), 0) AS totalMinutes
$fromClause
LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0
WHERE dop.deleted = 0
AND dop.ticketStatus = 'completed'
AND dop.ticketCompleteDateTime IS NOT NULL
$startSql $endSql $staffSql $storeSql
GROUP BY DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d'),
dop.handledBy, u.name, dop.handlerName
), 0) AS totalMinutes,
COALESCE(SUM(la.itemKindCount), 0) AS itemKindCount,
COALESCE(SUM(la.itemQtyPicked), 0) AS itemQtyPicked
FROM dop_scoped d
LEFT JOIN user u ON d.handledBy = u.id AND u.deleted = 0
LEFT JOIN lineAgg la ON la.dopId = d.id
GROUP BY DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d'),
d.handledBy, u.name, d.handlerName
ORDER BY date, orderCount DESC
""".trimIndent()
return jdbcDao.queryForList(sql, args)


+ 6
- 2
src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt Voir le fichier

@@ -194,9 +194,13 @@ class ChartController(
chartService.getStaffDeliveryPerformanceHandlers()

/**
* FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10
* GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002&storeId=2/F&storeIdNull=true
* Returns [{ date, staffName, orderCount, totalMinutes }]. Data from delivery_order_pick_order
* (handledBy), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime).
* Returns [{ date, staffName, orderCount, totalMinutes, itemKindCount, itemQtyPicked }].
* Data from delivery_order_pick_order (handledBy); orderCount = completed pick tickets;
* totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime);
* itemKindCount = sum of per-ticket COUNT(DISTINCT pick_order_line.itemId);
* itemQtyPicked = sum of stock_out_line.qty via pick_order_line.
* Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId).
*/
@GetMapping("/staff-delivery-performance")


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java Voir le fichier

@@ -10,6 +10,8 @@ public class ErrorCodes {

public static final String SEND_EMAIL_ERROR = "SEND_EMAIL_ERROR";
public static final String USERNAME_NOT_AVAILABLE = "USERNAME_NOT_AVAILABLE";
public static final String NAME_NOT_AVAILABLE = "NAME_NOT_AVAILABLE";
public static final String STAFF_NO_NOT_AVAILABLE = "STAFF_NO_NOT_AVAILABLE";

public static final String INIT_EXCEL_ERROR = "INIT_EXCEL_ERROR";



+ 17
- 5
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt Voir le fichier

@@ -244,9 +244,10 @@ open class DoWorkbenchMainService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13
* Warehouse exclude list for workbench **re-suggest** after scan-pick (shortfall / lot split).
* - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES]).
* - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else `null`.
* - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else empty (no limit).
* - DO / other: pass through request (`null` → service default excludes).
*/
private fun workbenchResuggestExcludeWarehouseCodes(
@@ -267,7 +268,7 @@ open class DoWorkbenchMainService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13
* Workbench scan-pick (DO FG):
* 1) Post outbound on scanned inventory lot line first; on failure return a clear message.
* 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty],
@@ -2364,6 +2365,7 @@ return MessageResponse(
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 */
private fun runWorkbenchPickDeferredFollowUps(
solId: Long,
polId: Long,
@@ -2386,10 +2388,20 @@ return MessageResponse(
var postMs = 0L
try {
if (pickOrderId != null) {
val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId)
// JO / Consumable prime with storeId=null; re-suggest must match or non-2F lots
// are filtered out after resolveWorkbenchSuggestionStoreId defaults to "2/F".
val resolvedPoType = pickOrderRepository.findById(pickOrderId).orElse(null)?.type
val suggestionStoreId =
if (resolvedPoType == PickOrderType.JOB_ORDER ||
resolvedPoType == PickOrderType.Consumable
) {
null
} else {
resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId)
}
val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes(
pickOrderId = pickOrderId,
poType = null,
poType = resolvedPoType,
userId = userId,
requestExcludeWarehouseCodes = effectiveExcludeWarehouseCodes,
)
@@ -2620,7 +2632,7 @@ return MessageResponse(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13
* DO user-audit: Just Complete is reportable only when the item still has pickable stock
* at complete time (AVAILABLE, not expired, in−out > 0).
*


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt Voir le fichier

@@ -3,7 +3,7 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models
import java.math.BigDecimal

/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13
* Workbench v1: scan lot and post pick immediately (no separate submit step).
* [qty] optional: when null, posts up to remaining quantity for this stock-out line chunk; when set, may exceed that
* chunk and is capped only by available quantity on the scanned inventory lot line (overscan / UI edit).


+ 41
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt Voir le fichier

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

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

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

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

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

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

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

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

+ 29
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt Voir le fichier

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

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

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

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

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

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

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

fun findByDeletedFalseOrderByMachineAscItemCodeAscFileNameAsc(): List<OnPackTemplateFile>
}

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt Voir le fichier

@@ -5,6 +5,7 @@ package com.ffii.fpsms.modules.jobOrder.service
*
* [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService])
* and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]).
* JO re-suggest keeps [storeId] null (same as assign); it does not use DO floor store resolution.
*/
object JoWorkbenchPickConstants {
val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf(


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt Voir le fichier

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


+ 45
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt Voir le fichier

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

import java.nio.charset.StandardCharsets

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

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

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

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

+ 152
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt Voir le fichier

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

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

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

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

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

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

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

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

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

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

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

+ 61
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt Voir le fichier

@@ -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"
}

+ 34
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt Voir le fichier

@@ -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))
}
}

+ 577
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt Voir le fichier

@@ -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,
)
}
}
}
}

+ 13
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt Voir le fichier

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

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

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

+ 454
- 85
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt Voir le fichier

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

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

data class OnPackZipResult(
val bytes: ByteArray,
val skippedWithoutExpiry: List<String> = emptyList(),
)

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

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

/**
* Laser TCP: `json;itemCode;itemName;;` or, when [expiryDate] is set,
* `json;itemCode;itemName;Expiry Date yyyyMMdd;;`.
* The lemon EZCAD job must bind a 4th text param or it replies invalid.
*/
fun buildLaserBag2Payload(
itemId: Long?,
stockInLineId: Long?,
itemCode: String?,
itemName: String?,
expiryDate: String? = null,
): String {
val codeStr = (itemCode ?: "").trim().replace(";", ",")
val nameStr = (itemName ?: "").trim().replace(";", ",")
val expStr = formatLaserExpiryParam(expiryDate)
val head = if (itemId != null && stockInLineId != null) {
"{\"itemId\":$itemId,\"stockInLineId\":$stockInLineId}"
} else {
"0"
}
return if (expStr.isNotEmpty()) {
"$head;$codeStr;$nameStr;$expStr;;"
} else {
"$head;$codeStr;$nameStr;;"
}
}

fun laserAckLooksInvalid(ack: String?): Boolean =
ack?.contains("invalid", ignoreCase = true) == true

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

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

@@ -193,6 +289,18 @@ class PlasticBagPrinterService(
* Bag2.py [send_job_to_laser] / [send_job_to_laser_with_retry]: UTF-8 TCP payload and optional ack read.
*/
fun sendLaserBag2Job(request: LaserBag2SendRequest): LaserBag2SendResponse {
return try {
sendLaserBag2JobInner(request)
} catch (e: Exception) {
logger.error("sendLaserBag2Job unexpected error", e)
LaserBag2SendResponse(
success = false,
message = "送出失敗:${e.message ?: e.javaClass.simpleName}",
)
}
}

private fun sendLaserBag2JobInner(request: LaserBag2SendRequest): LaserBag2SendResponse {
val ip = (request.printerIp?.trim()?.takeIf { it.isNotEmpty() }
?: resolveLaserBag2Host()).trim()
val port = request.printerPort ?: resolveLaserBag2Port()
@@ -203,32 +311,28 @@ class PlasticBagPrinterService(
stockInLineId = request.stockInLineId,
itemCode = request.itemCode,
itemName = request.itemName,
expiryDate = request.expiryDate,
)
val response = if (first.success) {
LaserBag2SendResponse(
success = true,
message = first.message,
payloadSent = first.payload,
printerAck = first.printerAck,
receiveAcknowledged = first.receiveAcknowledged,
)
val chosen = if (first.success) {
first
} else {
val second = sendLaserBag2TcpOnce(
sendLaserBag2TcpOnce(
ip = ip,
port = port,
itemId = request.itemId,
stockInLineId = request.stockInLineId,
itemCode = request.itemCode,
itemName = request.itemName,
)
LaserBag2SendResponse(
success = second.success,
message = second.message,
payloadSent = second.payload,
printerAck = second.printerAck,
receiveAcknowledged = second.receiveAcknowledged,
expiryDate = request.expiryDate,
)
}
val response = LaserBag2SendResponse(
success = chosen.success,
message = chosen.message,
payloadSent = chosen.payload,
printerAck = chosen.printerAck,
receiveAcknowledged = chosen.receiveAcknowledged,
)
if (response.success && response.receiveAcknowledged) {
try {
persistLaserLastReceiveSuccess(request, response.printerAck)
@@ -292,14 +396,9 @@ class PlasticBagPrinterService(
stockInLineId: Long?,
itemCode: String?,
itemName: String?,
expiryDate: String? = null,
): LaserBag2TcpResult {
val codeStr = (itemCode ?: "").trim().replace(";", ",")
val nameStr = (itemName ?: "").trim().replace(";", ",")
val payload = if (itemId != null && stockInLineId != null) {
"{\"itemId\":$itemId,\"stockInLineId\":$stockInLineId};$codeStr;$nameStr;;"
} else {
"0;$codeStr;$nameStr;;"
}
val payload = buildLaserBag2Payload(itemId, stockInLineId, itemCode, itemName, expiryDate)
val bytes = payload.toByteArray(StandardCharsets.UTF_8)
var socket: Socket? = null
try {
@@ -346,6 +445,15 @@ class PlasticBagPrinterService(
} catch (_: SocketTimeoutException) {
// Same as Python Bag3: ignore read timeout, payload was still sent
}
if (laserAckLooksInvalid(ackRaw)) {
return LaserBag2TcpResult(
false,
"檸檬機回覆 invalid,未接受指令。",
payload,
ackRaw,
false,
)
}
val msg = if (receiveAck) {
"已送出激光機:$payload(已確認)"
} else {
@@ -564,7 +672,18 @@ class PlasticBagPrinterService(
return baos.toByteArray()
}

fun generateOnPackQrZip(jobOrders: List<OnPackQrJobOrderRequest>): ByteArray {
/**
* 汁水機 OnPack: templates under classpath `onpack2030/{code}.image`.
* Always swaps LOGO_4 to the generated QR BMP.
* When [includeExpiry] is true, clones [OnPackPp1181Master] `.image` / `.job` for each code
* on the UI-managed `onpack_expiry_item_code` list, and generates product/code/date/expiry BMPs.
* Old ZIP callers must pass false; they keep per-code `onpack2030` templates unchanged.
*/
fun generateOnPackQrZip(
jobOrders: List<OnPackQrJobOrderRequest>,
includeExpiry: Boolean = false,
printDate: LocalDate? = null,
): OnPackZipResult {
val normalizedJobOrders = jobOrders
.map {
OnPackQrJobOrderRequest(
@@ -600,6 +719,12 @@ class PlasticBagPrinterService(
val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds }
require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" }

val expiryPrintNames = if (includeExpiry) {
onPackTemplateFileService.expiryPrintNames(OnPackTemplateFileService.MACHINE_JUICE)
} else {
emptyMap()
}

val exportItemsRaw = packagingJobOrders
.groupBy { it.itemCode.trim().lowercase() }
.mapNotNull { (codeLower, orders) ->
@@ -608,31 +733,146 @@ class PlasticBagPrinterService(
?: return@mapNotNull null
val itemId = stockInLine.item?.id ?: return@mapNotNull null
val stockInLineId = stockInLine.id ?: return@mapNotNull null
Triple(codeLower, itemId, stockInLineId)
val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null)
val baseName = jo?.bom?.name ?: stockInLine.item?.name
val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc
val computedName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc)
val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase()
val productName = expiryPrintNames[itemCode]?.trim()?.takeIf { it.isNotEmpty() }
?: computedName
?: itemCode
OnPackBmpExportItem(
codeLower,
itemId,
stockInLineId,
itemCode,
productName,
jo?.planStart?.toLocalDate(),
)
}

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

val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet()
val allowedBmpCodes = codesOnPackMatchingTemplateType(codesUpper, "bmp")
val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.first.uppercase()) }
val codesUpper = exportItemsRaw.map { it.itemCode }.toSet()
val allowedBmpCodes = if (includeExpiry) {
onPackTemplateFileService.expiryItemCodes(OnPackTemplateFileService.MACHINE_JUICE)
} else {
codesOnPackMatchingTemplateType(codesUpper, "bmp")
}
val exportItemsListed = exportItemsRaw.filter { allowedBmpCodes.contains(it.itemCode) }

require(exportItems.isNotEmpty()) { "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" }
require(exportItemsListed.isNotEmpty()) {
if (includeExpiry) {
"當日工單沒有在汁水機到期日 ZIP 品號清單中的項目(OnPack 模板可管理此清單)"
} else {
"No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates"
}
}

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

val skippedWithoutExpiry = mutableListOf<String>()
val exportItems = if (includeExpiry) {
exportItemsListed.filter { item ->
val label = itemDefaultShelfLifeService.expiryDatePrintLabel(item.itemCode, effectivePrintDate)
if (label.isNullOrBlank()) {
skippedWithoutExpiry += item.itemCode
false
} else {
true
}
}
} else {
exportItemsListed
}

require(exportItems.isNotEmpty()) {
if (includeExpiry && skippedWithoutExpiry.isNotEmpty()) {
"當日汁水機清單品號都沒有預設保質期,無法產生到期日 ZIP。請到設定 → 物品預設保質期新增:${skippedWithoutExpiry.joinToString("、")}"
} else if (includeExpiry) {
"當日工單沒有在汁水機到期日 ZIP 品號清單中的項目(OnPack 模板可管理此清單)"
} else {
"No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates"
}
}

val expiryMasterImage = if (includeExpiry) loadPp1181ExpiryMasterImage() else null
val expiryMasterJob = if (includeExpiry) loadPp1181MasterJob() else null
if (includeExpiry) {
require(expiryMasterImage != null) { "找不到 PP1181 到期日主模板(onpack2030_exp/pp1181.image)" }
}

val baos = ByteArrayOutputStream()
ZipOutputStream(baos).use { zos ->
val addedEntries = linkedSetOf<String>()
exportItems.forEach { (codeLower, itemId, stockInLineId) ->
val imageTemplate = loadOnPackImageTemplateOrNull(codeLower) ?: return@forEach
exportItems.forEach { item ->
val codeLower = item.codeLower
val imageTemplate = if (includeExpiry) {
val master = expiryMasterImage ?: return@forEach
OnPackPp1181Master.rewriteImageBytes(master, codeLower)
} else {
loadOnPackImageTemplateOrNull(codeLower, forExpiry = false) ?: return@forEach
}

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

if (addedEntries.add(qrBmpFileName)) {
addToZip(zos, qrBmpFileName, bmp.bytes)
@@ -640,19 +880,50 @@ class PlasticBagPrinterService(
if (addedEntries.add(imageFileName)) {
addToZip(zos, imageFileName, imageContent)
}
if (!includeExpiry) {
val decodedXmlForAssets = decodeOnPackImageTemplateForTextEdit(imageContent).first
extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName ->
if (bmpName.equals(qrBmpFileName, ignoreCase = true)) return@forEach
if (bmpName.endsWith("exp.bmp", ignoreCase = true)) return@forEach
if (bmpName.endsWith("Product.bmp", ignoreCase = true)) return@forEach
if (bmpName.endsWith("Code.bmp", ignoreCase = true)) return@forEach
if (bmpName.endsWith("Date.bmp", ignoreCase = true)) return@forEach
if (!addedEntries.add(bmpName)) return@forEach
val bmpBytes = loadJuiceAssetOrNull(bmpName) ?: return@forEach
addToZip(zos, bmpName, bmpBytes)
}
}
val jobFileName = "${codeLower}.job"
if (includeExpiry) {
val masterJob = expiryMasterJob
if (masterJob != null && addedEntries.add(jobFileName)) {
addToZip(zos, jobFileName, OnPackPp1181Master.rewriteJobBytes(masterJob, imageFileName))
}
} else {
loadJuiceAssetOrNull(jobFileName)?.let { jobBytes ->
if (addedEntries.add(jobFileName)) {
addToZip(zos, jobFileName, jobBytes)
}
}
}
}

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

return baos.toByteArray()
return OnPackZipResult(baos.toByteArray(), skippedWithoutExpiry.distinct())
}

/**
* OnPack2023 檸檬機: templates under classpath `onpack2030_2/{code}.image` with embedded QR (Static text).
* Only replaces `<Text>...</Text>` under `<Static type="StaticSrc.V1">` with JSON payload. No separate .bmp in zip.
* When [includeExpiry] is true, also injects TEXT_EXP (does not change TEXT_3 production date). Old ZIP callers must pass false.
*/
fun generateOnPackQrTextZip(jobOrders: List<OnPackQrJobOrderRequest>): ByteArray {
fun generateOnPackQrTextZip(
jobOrders: List<OnPackQrJobOrderRequest>,
includeExpiry: Boolean = false,
printDate: LocalDate? = null,
): OnPackZipResult {
val normalizedJobOrders = jobOrders
.map {
OnPackQrJobOrderRequest(
@@ -702,20 +973,56 @@ class PlasticBagPrinterService(

val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet()
val allowedTextCodes = codesOnPackMatchingTemplateType(codesUpper, "text")
val exportItems = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) }
val exportItemsListed = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) }

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

val effectivePrintDate = printDate ?: ItemDefaultShelfLifeService.today()
val skippedWithoutExpiry = mutableListOf<String>()
val exportItems = if (includeExpiry) {
exportItemsListed.filter { (codeLower, _, _) ->
val code = codeLower.uppercase()
val label = itemDefaultShelfLifeService.expiryDatePrintLabel(code, effectivePrintDate)
if (label.isNullOrBlank()) {
skippedWithoutExpiry += code
false
} else {
true
}
}
} else {
exportItemsListed
}

require(exportItems.isNotEmpty()) { "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" }
require(exportItems.isNotEmpty()) {
if (includeExpiry && skippedWithoutExpiry.isNotEmpty()) {
"當日檸檬機品號都沒有預設保質期,無法產生到期日 ZIP。請到設定 → 物品預設保質期新增:${skippedWithoutExpiry.joinToString("、")}"
} else {
"No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates"
}
}

val baos = ByteArrayOutputStream()
ZipOutputStream(baos).use { zos ->
val addedEntries = linkedSetOf<String>()
exportItems.forEach { (codeLower, itemId, stockInLineId) ->
val imageTemplate = loadOnPack2030_2ImageTemplateOrNull(codeLower) ?: run {
logger.warn("OnPack text ZIP: missing classpath template onpack2030_2/{}.image", codeLower.uppercase())
logger.warn("OnPack text ZIP: missing template for {}", codeLower.uppercase())
return@forEach
}
val imageFileName = "${codeLower.uppercase()}.image"
val imageContent = withOnPackStaticQrText(codeLower, imageTemplate, itemId, stockInLineId)
val expiryLabel = if (includeExpiry) {
itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate)
} else {
null
}
val imageContent = withOnPackStaticQrText(
codeLower,
imageTemplate,
itemId,
stockInLineId,
expiryCompact = expiryLabel,
)
if (addedEntries.add(imageFileName)) {
addToZip(zos, imageFileName, imageContent)
}
@@ -723,7 +1030,7 @@ class PlasticBagPrinterService(
extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName ->
if (!addedEntries.add(bmpName)) return@forEach
val bmpBytes = loadOnPack2030_2AssetOrNull(bmpName) ?: run {
logger.warn("OnPack text ZIP: missing classpath asset onpack2030_2/{}", bmpName)
logger.warn("OnPack text ZIP: missing asset {}", bmpName)
return@forEach
}
addToZip(zos, bmpName, bmpBytes)
@@ -737,7 +1044,7 @@ class PlasticBagPrinterService(
}
require(addedEntries.isNotEmpty()) { "No OnPack text template files could be generated for the selected date" }
}
return baos.toByteArray()
return OnPackZipResult(baos.toByteArray(), skippedWithoutExpiry.distinct())
}

/**
@@ -753,7 +1060,7 @@ class PlasticBagPrinterService(
)
}
val zipBytes = try {
generateOnPackQrTextZip(jobOrders)
generateOnPackQrTextZip(jobOrders).bytes
} catch (e: Exception) {
logger.warn("OnPack text ZIP generation failed before NGPCL push", e)
return NgpclPushResponse(
@@ -816,10 +1123,46 @@ class PlasticBagPrinterService(
else -> return emptySet()
}
val rows = jdbcDao.queryForList(sql, mapOf("codes" to codesUpper.toList()))
return rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet()
val fromTable = rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet()
val machine = if (normalizedType == "text") {
OnPackTemplateFileService.MACHINE_LEMON
} else {
OnPackTemplateFileService.MACHINE_JUICE
}
val fromDbFiles = onPackTemplateFileService.itemCodesWithImage(machine)
.filter { it in codesUpper }
.toSet()
return fromTable + fromDbFiles
}

private fun loadPp1181ExpiryMasterImage(): ByteArray? {
listOf(OnPackPp1181Master.MASTER_IMAGE, OnPackPp1181Master.MASTER_IMAGE_FALLBACK).forEach { path ->
val resource = ClassPathResource(path)
if (resource.exists()) {
return resource.inputStream.use { it.readBytes() }
}
}
logger.warn("Missing PP1181 expiry master image on classpath")
return null
}

private fun loadPp1181MasterJob(): ByteArray? {
val resource = ClassPathResource(OnPackPp1181Master.MASTER_JOB)
if (!resource.exists()) {
logger.warn("Missing PP1181 master job on classpath")
return null
}
return resource.inputStream.use { it.readBytes() }
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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


+ 92
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt Voir le fichier

@@ -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")))
}
}

+ 102
- 20
src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt Voir le fichier

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

import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService
import com.ffii.fpsms.modules.jobOrder.service.OnPackZipResult
import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService
import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest
import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest
@@ -60,11 +61,20 @@ class PlasticBagPrinterController(
}

/**
* Bag2.py laser TCP protocol: `{"itemId":n,"stockInLineId":m};code;name;;` or `0;code;name;;`
* Bag2/Bag4 laser TCP: `{"itemId":n,"stockInLineId":m};code;name;;` or
* `{"itemId":n,"stockInLineId":m};code;name;Expiry Date yyyyMMdd;;` when expiryDate is set.
*/
@PostMapping("/print-laser-bag2")
fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity<LaserBag2SendResponse> {
val resp = plasticBagPrinterService.sendLaserBag2Job(request)
val resp = try {
plasticBagPrinterService.sendLaserBag2Job(request)
} catch (e: Exception) {
logger.error("print-laser-bag2 failed", e)
LaserBag2SendResponse(
success = false,
message = "送出失敗:${e.message ?: e.javaClass.simpleName}",
)
}
return if (resp.success) {
ResponseEntity.ok(resp)
} else {
@@ -118,15 +128,8 @@ class PlasticBagPrinterController(
response: HttpServletResponse,
) {
try {
val zipBytes = plasticBagPrinterService.generateOnPackQrZip(request.jobOrders)
response.contentType = "application/zip"
response.setHeader(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"onpack_qr_codes.zip\""
)
response.setContentLength(zipBytes.size)
response.outputStream.write(zipBytes)
response.outputStream.flush()
val zip = plasticBagPrinterService.generateOnPackQrZip(request.jobOrders)
writeOnPackZip(response, "onpack_qr_codes.zip", zip)
} catch (e: IllegalArgumentException) {
response.status = HttpServletResponse.SC_BAD_REQUEST
response.contentType = "text/plain;charset=UTF-8"
@@ -148,6 +151,44 @@ class PlasticBagPrinterController(
}
}

/**
* Same 汁水機 ZIP as [downloadOnPackQr], plus expiry BMP from item_default_shelf_life.
* Clones PP1181 `.image` / `.job` for each code on `onpack_expiry_item_code`.
* Old [downloadOnPackQr] is unchanged.
*/
@PostMapping("/download-onpack-qr-with-expiry")
fun downloadOnPackQrWithExpiry(
@RequestBody request: OnPackQrDownloadRequest,
response: HttpServletResponse,
) {
try {
val zip = plasticBagPrinterService.generateOnPackQrZip(
request.jobOrders,
includeExpiry = true,
printDate = request.planDate,
)
writeOnPackZip(response, "onpack_qr_exp.zip", zip)
} catch (e: IllegalArgumentException) {
response.status = HttpServletResponse.SC_BAD_REQUEST
response.contentType = "text/plain;charset=UTF-8"
response.writer.write(e.message ?: "Invalid request")
response.writer.flush()
} catch (e: Exception) {
logger.error("POST /plastic/download-onpack-qr-with-expiry failed", e)
try {
if (!response.isCommitted) {
response.reset()
}
} catch (_: Exception) {
/* ignore */
}
response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR
response.contentType = "text/plain;charset=UTF-8"
response.writer.write(e.message ?: "Download failed")
response.writer.flush()
}
}

/** OnPack2023 檸檬機: `onpack2030_2` templates with embedded QR (Static text only; no separate .bmp). */
@PostMapping("/download-onpack-qr-text")
fun downloadOnPackQrText(
@@ -155,15 +196,8 @@ class PlasticBagPrinterController(
response: HttpServletResponse,
) {
try {
val zipBytes = plasticBagPrinterService.generateOnPackQrTextZip(request.jobOrders)
response.contentType = "application/zip"
response.setHeader(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"onpack2023_lemon_qr.zip\""
)
response.setContentLength(zipBytes.size)
response.outputStream.write(zipBytes)
response.outputStream.flush()
val zip = plasticBagPrinterService.generateOnPackQrTextZip(request.jobOrders)
writeOnPackZip(response, "onpack2023_lemon_qr.zip", zip)
} catch (e: IllegalArgumentException) {
response.status = HttpServletResponse.SC_BAD_REQUEST
response.contentType = "text/plain;charset=UTF-8"
@@ -194,6 +228,43 @@ class PlasticBagPrinterController(
return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders))
}

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

/**
* Test API to generate and download the printer job files as a ZIP.
* ONPACK2030
@@ -315,4 +386,15 @@ class PlasticBagPrinterController(
}
}

private fun writeOnPackZip(response: HttpServletResponse, filename: String, zip: OnPackZipResult) {
if (zip.skippedWithoutExpiry.isNotEmpty()) {
response.setHeader("X-OnPack-Skipped-Expiry", zip.skippedWithoutExpiry.joinToString(","))
}
response.contentType = "application/zip"
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"")
response.setContentLength(zip.bytes.size)
response.outputStream.write(zip.bytes)
response.outputStream.flush()
}

}

+ 33
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt Voir le fichier

@@ -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
}
}
}
}

+ 13
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt Voir le fichier

@@ -1,12 +1,18 @@
package com.ffii.fpsms.modules.jobOrder.web.model

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.databind.annotation.JsonDeserialize

/**
* Body for Bag2.py-style laser TCP send: `json;itemCode;itemName;;` (UTF-8).
* Body for Bag2/Bag4-style laser TCP send: `json;itemCode;itemName;;` or
* `json;itemCode;itemName;Expiry Date yyyyMMdd;;` when [expiryDate] is set (UTF-8).
* The lemon EZCAD job must bind the 4th `;` field or it replies invalid.
* Optional [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port].
*
* Optional job metadata is used to persist [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS]
* when the printer returns a receive ack.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class LaserBag2SendRequest(
val itemId: Long? = null,
val stockInLineId: Long? = null,
@@ -17,6 +23,12 @@ data class LaserBag2SendRequest(
val jobOrderId: Long? = null,
val jobOrderNo: String? = null,
val lotNo: String? = null,
/**
* Print-time expiry from the job-order list (`yyyy-MM-dd`, compact `yyyyMMdd`,
* Jackson `[yyyy,M,d]`, or already `Expiry Date yyyyMMdd`). Sent as the 4th TCP field.
*/
@JsonDeserialize(using = FlexibleExpiryDateDeserializer::class)
val expiryDate: String? = null,
/** AUTO (auto-send) or MANUAL (/laserPrint); optional. */
val source: String? = null,
)

+ 53
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt Voir le fichier

@@ -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,
)

+ 2
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt Voir le fichier

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

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

data class OnPackQrJobOrderRequest(


+ 43
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt Voir le fichier

@@ -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
}

+ 18
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt Voir le fichier

@@ -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>
}

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt Voir le fichier

@@ -18,6 +18,7 @@ interface ItemsRepository : AbstractRepository<Items, Long> {

fun findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?;
fun findByCodeAndDeletedFalse(code: String): Items?;
fun findByDeletedFalseAndCodeIn(codes: Collection<String>): List<Items>
fun findByNameAndDeletedFalse(name: String): Items?;

fun findByM18IdAndDeletedIsFalse(m18Id: Long): Items?;


+ 321
- 2
src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt Voir le fichier

@@ -75,6 +75,7 @@ open class BomService(
) {
companion object {
private const val BOM_WIP_DESCRIPTION = "WIP"
private const val BOM_DETAIL_EXPORT_TEMPLATE = "excelTemplate/bom_import_blank.xlsx"
}

open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse {
@@ -1127,7 +1128,8 @@ open class BomService(
}
3 -> {
val equipmentName = tempCell.stringCellValue.trim()
if (equipmentName != "不適用") {
// 不合用 / 不適用:不掛 equipment(與格式檢查同等處理)
if (!isNotApplicableEquipment(equipmentName)) {
val equipment = bomGetOrCreateEquipment(equipmentName)
// println("equipment created")
bomProcessRequest.equipment = equipment
@@ -1913,6 +1915,314 @@ open class BomService(
}
}

/** Fill blank import template from a saved BOM version (BOM 明細 → 匯出 Excel). */
@Transactional(readOnly = true)
open fun exportBomDetailExcel(id: Long): BomImportExportCorrectedResult {
val detail = getBomDetail(id)
val resource = ClassPathResource(BOM_DETAIL_EXPORT_TEMPLATE)
if (!resource.exists()) {
throw BadRequestException("BOM export template not found: $BOM_DETAIL_EXPORT_TEMPLATE")
}
resource.inputStream.use { input ->
val workbook: Workbook = XSSFWorkbook(input)
try {
val sheet = resolveImportBomSheet(workbook)
fillBomDetailOntoBlankTemplate(workbook, detail)
recalculateMaterialDerivedColumns(sheet)
refreshOutputUomFormulaDependents(sheet)
evaluateBlankTemplateFormulaDependents(sheet)
val bytes = ByteArrayOutputStream().use { out ->
workbook.write(out)
out.toByteArray()
}
val code = detail.itemCode?.trim().orEmpty().ifEmpty { "BOM" }
val rev = detail.revisionNo ?: 1
return BomImportExportCorrectedResult(
bytes = bytes,
downloadFileName = "${code}_V${rev}.xlsx",
)
} finally {
workbook.close()
}
}
}

/**
* Source cells on bom_import_blank.xlsx (食物成品):
* R5 name, R6 code (A2=R6, E2=R5), J2 kind, B4 stock qty, W5 stock unit code, S6 version.
*/
private fun fillBomDetailOntoBlankTemplate(workbook: Workbook, detail: BomDetailResponse) {
val sheet = resolveImportBomSheet(workbook)
detail.itemName?.trim()?.takeIf { it.isNotEmpty() }?.let {
getOrCreateCell(sheet, 4, 17).setCellValue(it)
}
detail.itemCode?.trim()?.takeIf { it.isNotEmpty() }?.let {
getOrCreateCell(sheet, 5, 17).setCellValue(it)
}
val kind = detail.bomKind?.trim()?.takeIf { it.isNotEmpty() }
?: detail.description?.trim()?.takeIf { it.isNotEmpty() }
kind?.let { getOrCreateCell(sheet, 1, 9).setCellValue(it) }
val rev = detail.revisionNo ?: 1
getOrCreateCell(sheet, 5, 18).setCellValue("V$rev")
val stockQty = detail.outputQtyStock ?: detail.outputQty
stockQty?.let {
getOrCreateCell(sheet, 3, 1).setCellValue(it.toDouble())
}
val stockUnitCode = detail.itemId
?.let { itemUomService.findStockUnitByItemId(it)?.uom?.code?.trim() }
?.takeIf { it.isNotEmpty() }
stockUnitCode?.let {
getOrCreateCell(sheet, 4, 22).setCellValue(it)
}
detail.isDark?.let { setBasicInfoScaleByHeaderContains(sheet, "深淺", it) }
detail.isFloat?.let { setBasicInfoScaleByHeaderContains(sheet, "浮沉", it) }
detail.isDense?.let { setBasicInfoScaleByHeaderContains(sheet, "濃淡", it) }
detail.scrapRate?.let { setBasicInfoScaleByHeaderContains(sheet, "損耗率", it) }
detail.timeSequence?.let { setBasicInfoScaleByHeaderContains(sheet, "生產時段先後數值", it) }
detail.complexity?.let { setBasicInfoScaleByHeaderContains(sheet, "複雜度", it) }
detail.allergicSubstances?.let { setBasicInfoAllergicSubstances(sheet, it) }
fillDbMaterialsOntoBlankTemplate(sheet, detail)
fillDbProcessesOntoBlankTemplate(sheet, detail.processes)
restoreSellingPriceHeaderFormulas(workbook)
fillProductRecipeSheet(workbook, detail)
}

/** Selling Price B1/B2/B3 follow 食物成品 name/code/version (original Excel formulas). */
private fun restoreSellingPriceHeaderFormulas(workbook: Workbook) {
val selling = workbook.getSheet("Selling Price (售價)") ?: return
setCellFormulaIfBlank(selling, 0, 1, "'食物成品 '!R5")
setCellFormulaIfBlank(selling, 1, 1, "'食物成品 '!A2")
setCellFormulaIfBlank(selling, 2, 1, "'食物成品 '!N1")
}

private fun setCellFormulaIfBlank(sheet: Sheet, rowIdx: Int, colIdx: Int, formula: String) {
val cell = getOrCreateCell(sheet, rowIdx, colIdx)
if (cell.cellType == CellType.FORMULA) return
if (cell.cellType != CellType.BLANK && readStringCellValue(cell) != null) return
cell.cellFormula = formula
}

/**
* 產品製方: title + version; materials fill 主要成份 then 其他汁料 (20+20);
* 製作方式 filled sequentially (A=1..n, B=description). Brand columns left empty.
*/
private fun fillProductRecipeSheet(workbook: Workbook, detail: BomDetailResponse) {
val sheet = workbook.getSheet("產品製方") ?: return
val code = detail.itemCode?.trim().orEmpty()
val name = detail.itemName?.trim().orEmpty()
val title = when {
code.isNotEmpty() && name.isNotEmpty() -> "產品名稱: ${code}_${name}"
name.isNotEmpty() -> "產品名稱: $name"
code.isNotEmpty() -> "產品名稱: $code"
else -> null
}
title?.let { getOrCreateCell(sheet, 3, 0).setCellValue(it) }
val rev = detail.revisionNo ?: 1
getOrCreateCell(sheet, 3, 10).setCellValue("V$rev")
fillProductRecipePackWeight(sheet, detail)
detail.outputQtyStock?.let {
getOrCreateCell(sheet, 7, 8).setCellValue(it.toDouble())
}

val leftNameCol = 1
val leftQtyCol = 3
val leftUomCol = 4
val rightNameCol = 8
val rightQtyCol = 10
val rightUomCol = 11
val leftStart = 14
val rightStart = 14
val slotsPerSide = 20
detail.materials.forEachIndexed { index, material ->
val itemName = material.itemName?.trim()?.takeIf { it.isNotEmpty() } ?: return@forEachIndexed
val uom = material.recipeUom?.trim()?.takeIf { it.isNotEmpty() }
?: material.recipeUomId?.let { uomConversionRepository.findById(it).orElse(null)?.code?.trim() }
val qty = material.recipeQty
if (index < slotsPerSide) {
val rowIdx = leftStart + index
getOrCreateCell(sheet, rowIdx, leftNameCol).setCellValue(itemName)
qty?.let { getOrCreateCell(sheet, rowIdx, leftQtyCol).setCellValue(it.toDouble()) }
uom?.let { getOrCreateCell(sheet, rowIdx, leftUomCol).setCellValue(it) }
} else {
val rowIdx = rightStart + (index - slotsPerSide)
if (index - slotsPerSide >= slotsPerSide) return@forEachIndexed
getOrCreateCell(sheet, rowIdx, rightNameCol).setCellValue(itemName)
qty?.let { getOrCreateCell(sheet, rowIdx, rightQtyCol).setCellValue(it.toDouble()) }
uom?.let { getOrCreateCell(sheet, rowIdx, rightUomCol).setCellValue(it) }
}
}

val processStart = findProductRecipeProcessStartRow(sheet)
val processEnd = processStart + 12
val processes = detail.processes.sortedBy { it.seqNo ?: Long.MAX_VALUE }
for (rowIdx in processStart..processEnd) {
val i = rowIdx - processStart
getOrCreateCell(sheet, rowIdx, 0).setCellValue((i + 1).toDouble())
val process = processes.getOrNull(i)
val text = process?.processDescription?.trim()?.takeIf { it.isNotEmpty() }
?: process?.processName?.trim()?.takeIf { it.isNotEmpty() }
if (text != null) {
getOrCreateCell(sheet, rowIdx, 1).setCellValue(text)
} else {
clearCellValue(sheet, rowIdx, 1)
}
}
}

/** I7 成品每包重量 = 1 stock unit converted to item base unit. */
private fun fillProductRecipePackWeight(sheet: Sheet, detail: BomDetailResponse) {
val itemId = detail.itemId ?: return
val stockUom = itemUomService.findStockUnitByItemId(itemId)?.uom ?: return
val stockUomId = stockUom.id ?: return
val packWeight = runCatching {
itemUomService.convertQtyToBaseQtyPrecise(itemId, stockUomId, BigDecimal.ONE)
}.getOrNull() ?: return
getOrCreateCell(sheet, 6, 8).setCellValue(packWeight.toDouble())
val baseCode = itemUomService.findBaseUnitByItemId(itemId)?.uom?.code?.trim()?.takeIf { it.isNotEmpty() }
if (baseCode != null && readStringCellValue(sheet.getRow(6)?.getCell(9)) == null) {
getOrCreateCell(sheet, 6, 9).setCellValue(baseCode)
}
}

private fun findProductRecipeProcessStartRow(sheet: Sheet): Int {
for (r in 30..45) {
val v = readStringCellValue(sheet.getRow(r)?.getCell(0)) ?: continue
if (v.contains("製作方式")) return r + 1
}
return 37
}

private fun fillDbMaterialsOntoBlankTemplate(sheet: Sheet, detail: BomDetailResponse) {
val headerRowIndex = findMaterialHeaderRowIndex(sheet) ?: return
val styleRowIdx = headerRowIndex + 1
val seqByProcessId = detail.processes.mapNotNull { p ->
val id = p.id ?: return@mapNotNull null
val seq = p.seqNo ?: return@mapNotNull null
id to seq
}.toMap()
detail.materials.forEachIndexed { index, material ->
val rowIdx = headerRowIndex + 1 + index
val itemCode = material.itemCode?.trim().orEmpty()
if (itemCode.isNotEmpty()) {
getOrCreateStyledCell(sheet, rowIdx, 0, styleRowIdx).setCellValue(itemCode)
getOrCreateStyledCell(sheet, rowIdx, 16, styleRowIdx).setCellValue(itemCode)
}
material.itemName?.trim()?.takeIf { it.isNotEmpty() }?.let {
getOrCreateStyledCell(sheet, rowIdx, 1, styleRowIdx).setCellValue(it)
getOrCreateStyledCell(sheet, rowIdx, 17, styleRowIdx).setCellValue(it)
}
material.recipeQty?.let {
getOrCreateStyledCell(sheet, rowIdx, 2, styleRowIdx).setCellValue(it.toDouble())
}
val uomCode = material.recipeUomId
?.let { uomConversionRepository.findById(it).orElse(null)?.code?.trim() }
?.takeIf { it.isNotEmpty() }
?: material.recipeUom?.trim()?.takeIf { it.isNotEmpty() }
uomCode?.let {
getOrCreateStyledCell(sheet, rowIdx, 3, styleRowIdx).setCellValue(it)
}
val joinSeq = material.processStepIds.mapNotNull { seqByProcessId[it] }.minOrNull()
joinSeq?.let {
getOrCreateStyledCell(sheet, rowIdx, 10, styleRowIdx).setCellValue(it.toDouble())
}
getOrCreateStyledCell(sheet, rowIdx, 15, styleRowIdx).setCellValue((index + 1).toDouble())
}
}

private fun fillDbProcessesOntoBlankTemplate(sheet: Sheet, processes: List<BomProcessDto>) {
val startRowIndex = findProcessDataStartRowIndex(sheet) ?: return
val styleRowIdx = startRowIndex
processes.sortedBy { it.seqNo ?: Long.MAX_VALUE }.forEachIndexed { index, process ->
val rowIdx = startRowIndex + index
process.seqNo?.let {
getOrCreateStyledCell(sheet, rowIdx, 0, styleRowIdx).setCellValue(it.toDouble())
}
val processLabel = process.processName?.trim()?.takeIf { it.isNotEmpty() }
?: process.processCode?.trim()?.takeIf { it.isNotEmpty() }
processLabel?.let {
getOrCreateStyledCell(sheet, rowIdx, 1, styleRowIdx).setCellValue(it)
}
process.processDescription?.let {
getOrCreateStyledCell(sheet, rowIdx, 2, styleRowIdx).setCellValue(it)
}
val equipmentText = formatProcessEquipmentForExcel(
BomImportPreviewProcessLine(
equipmentDescription = process.equipmentDescription,
equipmentName = process.equipmentName,
),
) ?: "不適用"
getOrCreateStyledCell(sheet, rowIdx, 3, styleRowIdx).setCellValue(equipmentText)
process.durationInMinute?.let {
getOrCreateStyledCell(sheet, rowIdx, 5, styleRowIdx).setCellValue(it.toDouble())
}
process.byProduct?.trim()?.takeIf { it.isNotEmpty() }?.let {
getOrCreateStyledCell(sheet, rowIdx, 8, styleRowIdx).setCellValue(it)
}
process.byProductUom?.trim()?.takeIf { it.isNotEmpty() }?.let {
getOrCreateStyledCell(sheet, rowIdx, 10, styleRowIdx).setCellValue(it)
}
process.prepTimeInMinute?.let {
getOrCreateStyledCell(sheet, rowIdx, 11, styleRowIdx).setCellValue(it.toDouble())
}
process.postProdTimeInMinute?.let {
getOrCreateStyledCell(sheet, rowIdx, 12, styleRowIdx).setCellValue(it.toDouble())
}
}
}

private fun findProcessDataStartRowIndex(sheet: Sheet): Int? {
var startRowIndex = 30
val maxHeaderSearch = 70
var headerFound = false
while (startRowIndex < maxHeaderSearch) {
val cell = sheet.getRow(startRowIndex)?.getCell(0)
if (cell != null && cell.cellType == CellType.STRING && cell.stringCellValue.trim() == "工序") {
headerFound = true
startRowIndex += 2
break
}
startRowIndex++
}
return if (headerFound) startRowIndex else null
}

private fun getOrCreateStyledCell(
sheet: Sheet,
rowIdx: Int,
colIdx: Int,
styleRowIdx: Int,
): org.apache.poi.ss.usermodel.Cell {
val row = sheet.getRow(rowIdx) ?: sheet.createRow(rowIdx).also { created ->
sheet.getRow(styleRowIdx)?.let { created.height = it.height }
}
val existing = row.getCell(colIdx)
if (existing != null) return existing
val created = row.createCell(colIdx)
sheet.getRow(styleRowIdx)?.getCell(colIdx)?.cellStyle?.let { created.cellStyle = it }
return created
}

private fun evaluateBlankTemplateFormulaDependents(sheet: Sheet) {
val workbook = sheet.workbook
val evaluator = workbook.creationHelper.createFormulaEvaluator()
val cells = mutableListOf(
sheet.getRow(1)?.getCell(0), // A2 =R6
sheet.getRow(1)?.getCell(4), // E2 =R5
sheet.getRow(0)?.getCell(13), // N1 =S6
)
val selling = workbook.getSheet("Selling Price (售價)")
if (selling != null) {
cells += selling.getRow(0)?.getCell(1) // B1 ='食物成品 '!R5
cells += selling.getRow(1)?.getCell(1) // B2 ='食物成品 '!A2
cells += selling.getRow(2)?.getCell(1) // B3 ='食物成品 '!N1
}
for (cell in cells) {
if (cell != null && cell.cellType == CellType.FORMULA) {
evaluator.evaluateFormulaCell(cell)
}
}
}

private fun resolveImportBomFilePath(batchId: String, fileName: String): Path? {
val path = getBatchDir(batchId).resolve(fileName)
return if (Files.exists(path)) path else null
@@ -2467,11 +2777,17 @@ open class BomService(
}
return lines
}
/** FP-MTMS Version Checklist | Functions Ref. No. 63 | v1.0.0 | 2026-08-10 */
/** BOM 工序「使用設備」為不合用/不適用時,不掛 equipment FK */
private fun isNotApplicableEquipment(value: String): Boolean {
val trimmed = value.trim()
return trimmed == "不合用" || trimmed == "不適用"
}

private fun isValidEquipmentType(value: String): Boolean {
val trimmed = value.trim()
if (trimmed.isEmpty()) return false
if (trimmed == "不合用" || trimmed == "不適用") return true
if (isNotApplicableEquipment(trimmed)) return true
if (trimmed.contains(",")) return false // 新增:不允許逗號
val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機
return regex.matches(trimmed)
@@ -3508,8 +3824,11 @@ for (r in 0..20) {
processCode = p.process?.code,
processName = p.process?.name,
processDescription = p.description,
byProduct = p.byProduct,
byProductUom = p.byProductUom,
equipmentCode = p.equipment?.code,
equipmentName = p.equipment?.name,
equipmentDescription = p.equipment?.description,
durationInMinute = p.durationInMinute,
prepTimeInMinute = p.prepTimeInMinute,
postProdTimeInMinute = p.postProdTimeInMinute,


+ 242
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt Voir le fichier

@@ -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")
}
}
}
}

+ 33
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt Voir le fichier

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

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

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

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

+ 55
- 18
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt Voir le fichier

@@ -634,6 +634,10 @@ open fun listBagItemsCombo(): List<Map<String, String>> {
return itemsRepository.findByCodeAndDeletedFalse(code);
}

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

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


+ 9
- 0
src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt Voir le fichier

@@ -242,6 +242,15 @@ fun getBomDetail(@PathVariable id: Long): BomDetailResponse {
return bomService.getBomDetail(id)
}

@GetMapping("/{id}/export-excel")
fun exportBomDetailExcel(@PathVariable id: Long): ResponseEntity<Resource> {
val result = bomService.exportBomDetailExcel(id)
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment")
.header(HttpHeaders.CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
.body(ByteArrayResource(result.bytes))
}

/** FP-MTMS Version Checklist | Functions Ref. No. 17 | v1.0.0 | 2026-07-16 */
@PostMapping("/{id}/activate-version")
fun activateBomVersion(@PathVariable id: Long): BomDetailResponse {


+ 64
- 0
src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt Voir le fichier

@@ -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,
)

+ 2
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt Voir le fichier

@@ -159,6 +159,7 @@ fun findCompletedWithPlasticBoxCartonQtyInPlanStartRange(
): List<PickOrder>


/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.2 | 2026-08-25 */
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
value = """
@@ -169,6 +170,7 @@ fun findCompletedWithPlasticBoxCartonQtyInPlanStartRange(
modifiedBy = :modifiedBy
WHERE deliveryOrderPickOrderId = :dopoId
AND deleted = 0
AND LOWER(COALESCE(status, '')) <> 'completed'
""",
nativeQuery = true
)


+ 6
- 3
src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt Voir le fichier

@@ -6,13 +6,16 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants
* Temporary consumable workbench exclude list until per-user DB config (Scheme A) lands.
*
* - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants]).
* - All other users → `null` → DO 2F default excludes in [SuggestedPickLotWorkbenchService].
* - All other users → empty list (no warehouse limit). Do not return null:
* [SuggestedPickLotWorkbenchService] treats null as DO default 2F excludes.
* Consumable re-suggest keeps storeId null (same as first prime / JO); it does not use DO floor store resolution.
*/
object ConsumableWorkbenchPickConstants {
const val HARDCODED_EXCLUDE_USER_ID: Long = 246L

fun resolveExcludeWarehouseCodes(userId: Long): List<String>? {
if (userId != HARDCODED_EXCLUDE_USER_ID) return null
/** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.0 | 2026-08-13 */
fun resolveExcludeWarehouseCodes(userId: Long): List<String> {
if (userId != HARDCODED_EXCLUDE_USER_ID) return emptyList()
return JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES.toList()
}
}

+ 2
- 1
src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt Voir le fichier

@@ -29,12 +29,13 @@ interface ProductProcessLineRepository : JpaRepository<ProductProcessLine, Long>
""")
fun findByProductProcess_IdInWithOperatorAndEquipment(@Param("ids") ids: List<Long>): List<ProductProcessLine>

/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */
@Query(
"""
SELECT
p.jobOrder.id AS jobOrderId,
COUNT(l.id) AS totalLines,
SUM(CASE WHEN l.status IN ('Completed', 'Pass') THEN 1 ELSE 0 END) AS doneLines
SUM(CASE WHEN l.status IN ('Completed', 'Pass', 'autoPass') THEN 1 ELSE 0 END) AS doneLines
FROM ProductProcessLine l
JOIN l.productProcess p
WHERE l.deleted = false


+ 400
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/service/DrinkShipmentQtyService.kt Voir le fichier

@@ -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()
}
}

+ 29
- 34
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt Voir le fichier

@@ -1307,7 +1307,7 @@ open class ProductProcessService(
)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */
/** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */
open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse {
println(" Service: Updating ProductProcessLine Status: $productProcessLineId")
val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null)
@@ -1317,8 +1317,8 @@ open class ProductProcessService(
productProcessLineRepository.save(productProcessLine)
println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}")

// One packaging Complete/Pass → auto-Pass remaining packaging lines on the same JO.
if (isLineDone(status) && isPackagingLine(productProcessLine)) {
// One packaging Complete/Pass → autoPass remaining packaging lines on the same JO.
if (isManualLineDone(status) && isPackagingLine(productProcessLine)) {
autoPassSiblingPackagingLines(productProcessLineId)
}

@@ -1376,7 +1376,7 @@ open class ProductProcessService(
val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId)

println(" Service: ProductProcessLines: $productProcessLines")
if (productProcessLines.all { it.status == "Completed" || it.status == "Pass" }) {
if (productProcessLines.all { isLineDone(it.status) }) {
productProcess.status = ProductProcessStatus.COMPLETED
if (productProcess.endTime == null) {
productProcess.endTime = LocalDateTime.now()
@@ -1568,9 +1568,9 @@ bomDescription = productProcesses.bom?.bomKind,
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.2 | 2026-08-09
* QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。
* (包裝 Complete/Pass 時會自動 Pass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。)
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10
* QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass/autoPass 决定。
* (包裝 Complete/Pass 時會自動 autoPass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。)
*
* 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。
*/
@@ -1720,10 +1720,6 @@ bomDescription = productProcesses.bom?.bomKind,
val linesByJobOrder = candidateLines.groupBy { it.productProcess.jobOrder?.id ?: 0L }
val done = { s: String? ->
val x = s?.trim()?.lowercase()
x == "completed" || x == "pass"
}
val qcKeys = candidateAggregates.mapNotNull { agg ->
val jobOrderId = agg.jobOrderId
val stockInLine = stockInLineByJobOrderId[jobOrderId]
@@ -1745,15 +1741,10 @@ bomDescription = productProcesses.bom?.bomKind,

val lineAggregate = lineStatusByJobOrderId[jobOrderId]
val totalLines = lineAggregate?.totalLines ?: 0L
val done = { s: String? ->
val x = s?.trim()?.lowercase()
x == "completed" || x == "pass"
}
// After packaging Complete/Pass auto-passes sibling 包裝 lines, qcReady is simply all lines done.

// After packaging Complete/Pass autoPasses sibling 包裝 lines, qcReady is simply all lines done.
val jobLines = linesByJobOrder[jobOrderId].orEmpty()
val allLinesDone = jobLines.isNotEmpty() && jobLines.all { done(it.status) }
val allLinesDone = jobLines.isNotEmpty() && jobLines.all { isLineDone(it.status) }

val ready = includedInList && stockInLine != null && allLinesDone
@@ -1943,10 +1934,7 @@ bomDescription = productProcesses.bom?.bomKind,
val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList()

val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty()
val finishedCount = productProcessLines.count {
val s = it.status?.trim()?.lowercase()
s == "completed" || s == "pass"
}
val finishedCount = productProcessLines.count { isLineDone(it.status) }

val bomIsDrink = productProcess.bom?.isDrink
val matchStatus = if (joPickOrdersList.isNotEmpty() &&
@@ -2020,7 +2008,7 @@ bomDescription = productProcess.bom?.bomKind,
}

/**
* FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass, stock-in exists and not completed/rejected;
* FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass/autoPass, stock-in exists and not completed/rejected;
* only job orders that have a product process dated **today or yesterday** (server local date).
*/
open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse {
@@ -2074,7 +2062,7 @@ bomDescription = productProcess.bom?.bomKind,

val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() }
val allLinesDone = jobOrderLines.isNotEmpty() &&
jobOrderLines.all { it.status == "Completed" || it.status == "Pass" }
jobOrderLines.all { isLineDone(it.status) }
if (!allLinesDone) continue

val maxDate = processes.mapNotNull { it.date }.maxOrNull()
@@ -2176,6 +2164,12 @@ bomDescription = productProcess.bom?.bomKind,
status?.trim()?.lowercase()?.replace(" ", "") ?: ""

private fun isLineDone(status: String?): Boolean {
val n = normalizeLineStatus(status)
return n == "completed" || n == "pass" || n == "autopass"
}

/** Manual Complete/Pass only — does not include autoPass (avoids cascade loops). */
private fun isManualLineDone(status: String?): Boolean {
val n = normalizeLineStatus(status)
return n == "completed" || n == "pass"
}
@@ -2185,14 +2179,14 @@ bomDescription = productProcess.bom?.bomKind,
return n == "inprogress" || n == "paused"
}

/** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */
/** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */
private fun isPackagingLine(line: ProductProcessLine): Boolean =
(line.name ?: "").trim() == PACKAGING_PROCESS_NAME

/**
* FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09
* When one 包裝 line is Completed/Pass, auto-Pass other unfinished 包裝 lines on the same job order,
* then sync each affected ProductProcess.status (Pass counts as done → COMPLETED).
* FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10
* When one 包裝 line is Completed/Pass, set other unfinished 包裝 lines on the same job order to autoPass,
* then sync each affected ProductProcess.status (autoPass counts as done → COMPLETED).
*/
private fun autoPassSiblingPackagingLines(triggerLineId: Long) {
val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return
@@ -2220,7 +2214,7 @@ bomDescription = productProcess.bom?.bomKind,
line.startTime = now
}
line.endTime = now
line.status = "Pass"
line.status = "autoPass"
productProcessLineRepository.save(line)
line.productProcess?.id?.let { affectedProcessIds.add(it) }
}
@@ -2232,7 +2226,7 @@ bomDescription = productProcess.bom?.bomKind,

/**
* Align parent [ProductProcess.status] with all line states.
* - All Completed/Pass -> completed (via [ifAllLinesCompletedOrPassed])
* - All Completed/Pass/autoPass -> completed (via [ifAllLinesCompletedOrPassed])
* - Any line started but not all done -> in_progress
* - Does not override STOPPED or CANCELLED on the parent.
*/
@@ -2312,8 +2306,8 @@ bomDescription = productProcess.bom?.bomKind,
// 获取所有 product process lines
val allproductProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId)

// 检查是否所有 lines 都是 "Completed" 或 "Pass"
if (allproductProcessLines.all { it.status == "Completed" || it.status == "Pass" }) {
// 检查是否所有 lines 都是 Completed / Pass / autoPass
if (allproductProcessLines.all { isLineDone(it.status) }) {
// 更新 product process 的 endTime 和状态
updateProductProcessEndTime(productProcessId)
updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED)
@@ -2828,6 +2822,7 @@ bomDescription = productProcess.bom?.bomKind,
}

val drinkOrders = candidateJobOrders
.filter { it.isHidden != true }
.filter { it.bom?.isDrink == true }
.filter { jo ->
isPlannedView || jo.status != JobOrderStatus.PLANNING
@@ -3154,7 +3149,7 @@ bomDescription = productProcess.bom?.bomKind,
}

val currentProcesses = operatorLines
.filter { it.endTime == null || (it.status != null && it.status != "Completed" && it.status != "Pass") }
.filter { it.endTime == null || (it.status != null && !isLineDone(it.status)) }
.map { line ->
val productProcess = line.productProcess
val jobOrder = productProcess.jobOrder


+ 14
- 2
src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt Voir le fichier

@@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.productProcess.web
import com.ffii.fpsms.modules.productProcess.entity.ProductProcess
import com.ffii.fpsms.modules.productProcess.entity.ProductProcessLine
import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus
import com.ffii.fpsms.modules.productProcess.service.DrinkShipmentQtyService
import com.ffii.fpsms.modules.productProcess.service.ProductProcessService
import com.ffii.fpsms.modules.productProcess.web.model.*
import org.springframework.data.domain.Page
@@ -16,7 +17,8 @@ import java.time.format.DateTimeFormatter
@RestController
@RequestMapping("/product-process")
class ProductProcessController(
private val productProcessService: ProductProcessService
private val productProcessService: ProductProcessService,
private val drinkShipmentQtyService: DrinkShipmentQtyService,
) {

@GetMapping
@@ -200,7 +202,7 @@ class ProductProcessController(
return productProcessService.getAllJoborderProductProcessInfo(bomType)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 */
/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */
@GetMapping("/Demo/Process/search")
fun demoprocesssearch(
@RequestParam(required = false) date: String?,
@@ -333,4 +335,14 @@ class ProductProcessController(
}
return productProcessService.getDrinkProductionQty(parsedDate, viewMode)
}

@GetMapping("/Demo/DrinkShipmentQty")
fun getDrinkShipmentQty(
@RequestParam(required = false) date: String?,
): List<DrinkShipmentQtyResponse> {
val parsedDate = date?.takeIf { it.isNotBlank() }?.let {
LocalDate.parse(it, DateTimeFormatter.ISO_DATE)
}
return drinkShipmentQtyService.getDrinkShipmentQty(parsedDate)
}
}

+ 22
- 1
src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt Voir le fichier

@@ -218,7 +218,7 @@ data class JobOrderProductProcessPageResponse(
)

/**
* Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass, stock-in not completed/rejected;
* Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass/autoPass, stock-in not completed/rejected;
* job order has at least one process dated today or yesterday. [qc] = before received; [putAway] = received / partially_completed.
*/
data class JobOrderFgAlertRowResponse(
@@ -400,4 +400,25 @@ data class DrinkProductionQtyResponse(
val totalReqQty: BigDecimal,
val totalQty: BigDecimal,
val jobOrders: List<DrinkProductionQtyJobOrderDetail> = emptyList(),
)

data class DrinkShipmentQtyDeliveryDetail(
val deliveryOrderId: Long,
val deliveryOrderCode: String?,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
val deliveryDate: LocalDate?,
val shopCode: String?,
val shopName: String?,
val deliveryOrderStatus: String?,
val orderQty: BigDecimal,
val shippedQty: BigDecimal,
)

data class DrinkShipmentQtyResponse(
val itemCode: String?,
val itemName: String?,
val uom: String?,
val totalOrderQty: BigDecimal,
val totalShippedQty: BigDecimal,
val deliveries: List<DrinkShipmentQtyDeliveryDetail> = emptyList(),
)

+ 128
- 0
src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt Voir le fichier

@@ -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)
}
}

+ 6
- 2
src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt Voir le fichier

@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service
open class ItemQcFailReportService(
private val jdbcDao: JdbcDao,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */
fun searchItemQcFailReport(
stockCategory: String?,
itemCode: String?,
@@ -33,16 +34,19 @@ open class ItemQcFailReportService(
val qcItemScopeSql = buildQcItemScopeClause(measurable, other)
val measuredValueSql = buildMeasuredValueClause(measurable, scope)

// Date filter = QC result created time (true QC finish), not receiptDate / put-away.
// JO EPQC: receiptDate is often SIL create day; put-away ≈ QC via auto put-away.
// PO IQC: put-away can be days after QC — must not use ill.created for this filter.
val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) {
val formattedDate = lastInDateStart.replace("/", "-")
args["lastInDateStart"] = formattedDate
"AND DATE(sil.receiptDate) >= DATE(:lastInDateStart)"
"AND DATE(qr.created) >= DATE(:lastInDateStart)"
} else ""

val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) {
val formattedDate = lastInDateEnd.replace("/", "-")
args["lastInDateEnd"] = formattedDate
"AND DATE(sil.receiptDate) <= DATE(:lastInDateEnd)"
"AND DATE(qr.created) <= DATE(:lastInDateEnd)"
} else ""

val sql = """


+ 25
- 7
src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt Voir le fichier

@@ -15,6 +15,7 @@ import java.math.BigDecimal
class ShopOrderReplenishmentReportService(
private val jdbcDao: JdbcDao,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 49 | v1.0.1 | 2026-08-10 */
fun searchShopOrderReplenishmentReport(
reorderDateStart: String?,
reorderDateEnd: String?,
@@ -87,6 +88,7 @@ class ShopOrderReplenishmentReportService(
DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate,
dr.reason AS reason,
DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate,
TRIM(IFNULL(dop.handlerName, '')) AS actualDeliveredHandler,
dr.sourceDoId AS sourceDoId,
dr.itemId AS itemId,
dr.pickOrderLineId AS pickOrderLineId
@@ -130,7 +132,7 @@ class ShopOrderReplenishmentReportService(
val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct()

val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds)
val firstOrderPickBySourceKey = loadFirstOrderActualPickQty(sourceDoIds, itemIds, pickOrderLineIds)
val firstOrderPickBySourceKey = loadFirstOrderPickInfo(sourceDoIds, itemIds, pickOrderLineIds)

val rows = baseRows.map { row ->
val polId = longVal(row["pickOrderLineId"])
@@ -138,6 +140,7 @@ class ShopOrderReplenishmentReportService(
val itemId = longVal(row["itemId"])
val sourceKey =
if (sourceDoId != null && itemId != null) sourceDoId to itemId else null
val firstOrder = sourceKey?.let { firstOrderPickBySourceKey[it] }

linkedMapOf<String, Any?>(
"shopNo" to row["shopNo"],
@@ -147,11 +150,13 @@ class ShopOrderReplenishmentReportService(
"itemNo" to row["itemNo"],
"itemName" to row["itemName"],
"firstOrderQty" to row["firstOrderQty"],
"firstOrderActualPickQty" to (sourceKey?.let { firstOrderPickBySourceKey[it] } ?: BigDecimal.ZERO),
"firstOrderActualPickQty" to (firstOrder?.qty ?: BigDecimal.ZERO),
"firstOrderPickerHandler" to (firstOrder?.handler ?: ""),
"reorderQty" to row["reorderQty"],
"reorderDate" to row["reorderDate"],
"reason" to row["reason"],
"actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO),
"actualDeliveredHandler" to (row["actualDeliveredHandler"] ?: ""),
"deliveredDate" to row["deliveredDate"],
)
}
@@ -185,14 +190,15 @@ class ShopOrderReplenishmentReportService(
}

/**
* Sum stock_out_line.qty for source DO pick order lines of the same item,
* First-order pick qty + handler for source DO + item,
* excluding any POL that is itself a replenishment line (incl. current report POLs).
* Same ticket has at most one handler; MAX() is for GROUP BY only.
*/
private fun loadFirstOrderActualPickQty(
private fun loadFirstOrderPickInfo(
sourceDoIds: List<Long>,
itemIds: List<Long>,
excludePickOrderLineIds: List<Long>,
): Map<Pair<Long, Long>, BigDecimal> {
): Map<Pair<Long, Long>, FirstOrderPickInfo> {
if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap()

val args = mutableMapOf<String, Any>(
@@ -210,7 +216,8 @@ class ShopOrderReplenishmentReportService(
SELECT
po.doId AS sourceDoId,
pol.itemId AS itemId,
SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty
SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty,
MAX(TRIM(IFNULL(dop.handlerName, ''))) AS firstOrderPickerHandler
FROM pick_order po
INNER JOIN pick_order_line pol
ON pol.poId = po.id
@@ -218,6 +225,9 @@ class ShopOrderReplenishmentReportService(
LEFT JOIN stock_out_line sol
ON sol.pickOrderLineId = pol.id
AND IFNULL(sol.deleted, 0) = 0
LEFT JOIN delivery_order_pick_order dop
ON dop.id = po.deliveryOrderPickOrderId
AND IFNULL(dop.deleted, 0) = 0
WHERE IFNULL(po.deleted, 0) = 0
AND po.doId IN (:sourceDoIds)
AND pol.itemId IN (:itemIds)
@@ -236,10 +246,18 @@ class ShopOrderReplenishmentReportService(
return rows.mapNotNull { row ->
val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null
val itemId = longVal(row["itemId"]) ?: return@mapNotNull null
(sourceDoId to itemId) to decimalVal(row["firstOrderActualPickQty"])
(sourceDoId to itemId) to FirstOrderPickInfo(
qty = decimalVal(row["firstOrderActualPickQty"]),
handler = row["firstOrderPickerHandler"]?.toString()?.trim().orEmpty(),
)
}.toMap()
}

private data class FirstOrderPickInfo(
val qty: BigDecimal,
val handler: String,
)

private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-")

private fun dateStartClause(


+ 182
- 194
src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt Voir le fichier

@@ -4,17 +4,25 @@ import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.format.DateTimeFormatter

@Service
open class StockLedgerReportService(
private val jdbcDao: JdbcDao,
) {

private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd")

/**
* FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11
* Stock Ledger 報表查詢
*
* - stockSubCategory = items.type
* - trnDate = stock_ledger.date
* - trnRefNo = stock_ledger.type
* - cumBalance = stock_ledger.balance(異動後纍計存量)
* - cumOpeningBal = balance - inQty + outQty(異動前纍計期初)
*
* 只查 [start, end] 期間列,不掃起日以前全歷史。
*/
fun searchStockLedgerReport(
stockCategory: String?,
@@ -23,18 +31,15 @@ open class StockLedgerReportService(
reportPeriodStart: String?,
reportPeriodEnd: String?,
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
// 1) 先決定 reportPeriodEnd:如果有填 end,就用使用者的;否則用今天

val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-")
?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
// 2) 如果有填 start,就用使用者的;否則從 DB 查最早一筆日期
?: LocalDate.now().format(dateFmt))

val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) {
reportPeriodStart.replace("/", "-")
} else {
// 用簡單 SQL 查全表最早一筆日期(可加上類似 stockCategory/itemCode 過濾)
val minDateSql = """
SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate
FROM stock_ledger sl
@@ -42,188 +47,39 @@ open class StockLedgerReportService(
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
""".trimIndent()
val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull()
(minDateRow?.get("firstDate") as? String)
?: reportPeriodEnd // 如果表是空的,就退回用今天
?: reportPeriodEnd
}
// 3) 把 from/to 塞到 args,供後面 SQL 使用
val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt)
args["reportPeriodStart"] = reportPeriodStart
args["reportPeriodEnd"] = reportPeriodEnd
// 4) 之後再用你原來的 stockCategorySql / itemCodeSql / storeLocationSql
args["reportPeriodEndExclusive"] = endExclusive

val stockCategorySql = buildMultiValueExactClause(
stockCategory,
"it.type",
"stockCategory",
args
)
val itemCodeSql = buildMultiValueLikeClause(
itemCode,
"sl.itemCode",
"itemCode",
args
)

// 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數
val storeLocationSql = if (!storeLocation.isNullOrBlank()) {
args["storeLocation"] = "%$storeLocation%"
"AND (wh_in.code LIKE :storeLocation OR wh_out.code LIKE :storeLocation)"
"AND lot.storeLocation LIKE :storeLocation"
} else {
""
}
val reportPeriodEndSql = "AND DATE(sl.date) <= :reportPeriodEnd"

val sql = """
WITH base AS (
SELECT
sl.id AS slId,
DATE(sl.date) AS trnDateRaw,
DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate,
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE'
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ'
AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE'
ELSE COALESCE(sl.type, '')
END AS trnRefNoRaw,

sl.itemId AS itemId,
sl.itemCode AS itemCode,

COALESCE(sl.inQty, 0) AS inQty,
COALESCE(sl.outQty, 0) AS outQty,
(COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS delta,

it.type AS stockSubCategory,
it.code AS itemNo,
it.name AS itemName,
uc.udfudesc AS unitOfMeasure,

lot.lotNo AS lotNo,
COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
lot.storeLocation AS storeLocation,

COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo,
COALESCE(TRIM(jo.code), '') AS jobOrderNo,

'' AS openingBalance,
'' AS cumStockIn,
'' AS cumStockOut,
'' AS currentBalance,
'' AS lastInDate,
'' AS lastOutDate,
'' AS reOrderLevel,
'' AS reOrderQty
FROM stock_ledger sl
LEFT JOIN stock_in_line sil
ON sl.stockInLineId = sil.id
AND sil.deleted = 0
LEFT JOIN inventory_lot il_in
ON sil.inventoryLotId = il_in.id
AND il_in.deleted = 0

LEFT JOIN stock_out_line sol
ON sl.stockOutLineId = sol.id
AND sol.deleted = 0
LEFT JOIN inventory_lot_line ill_out
ON sol.inventoryLotLineId = ill_out.id
AND ill_out.deleted = 0
LEFT JOIN inventory_lot il_out
ON ill_out.inventoryLotId = il_out.id
AND il_out.deleted = 0

LEFT JOIN (
SELECT
il.id AS lotId,
il.lotNo AS lotNo,
il.expiryDate AS expiryDate,
MAX(wh.code) AS storeLocation
FROM inventory_lot il
LEFT JOIN inventory_lot_line ill
ON ill.inventoryLotId = il.id
AND ill.deleted = 0
LEFT JOIN warehouse wh
ON ill.warehouseId = wh.id
AND wh.deleted = 0
GROUP BY
il.id, il.lotNo, il.expiryDate
) lot
ON lot.lotId = COALESCE(il_in.id, il_out.id)

LEFT JOIN items it
ON sl.itemId = it.id
AND it.deleted = 0
LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id

-- 這兩個 alias 是為了配合你上面 storeLocationSql 的 wh_in / wh_out
LEFT JOIN inventory_lot_line ill_in
ON il_in.id = ill_in.inventoryLotId
AND ill_in.deleted = 0
LEFT JOIN warehouse wh_in
ON ill_in.warehouseId = wh_in.id
AND wh_in.deleted = 0
LEFT JOIN warehouse wh_out
ON ill_out.warehouseId = wh_out.id
AND wh_out.deleted = 0
LEFT JOIN stock_out so
ON sol.stockOutId = so.id
AND so.deleted = 0
LEFT JOIN pick_order_line pol
ON sol.pickOrderLineId = pol.id
AND pol.deleted = 0
LEFT JOIN pick_order po_out
ON pol.poId = po_out.id
AND po_out.deleted = 0
LEFT JOIN job_order jo_po
ON po_out.joId = jo_po.id
AND jo_po.deleted = 0
LEFT JOIN delivery_order do
ON po_out.doId = do.id
AND do.deleted = 0
LEFT JOIN stock_in si
ON sil.stockInId = si.id
AND si.deleted = 0
LEFT JOIN job_order jo
ON sil.jobOrderId = jo.id
AND jo.deleted = 0
LEFT JOIN purchase_order po
ON sil.purchaseOrderId = po.id
AND po.deleted = 0
WHERE
sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
AND DATE(sl.date) <= :reportPeriodEnd
$stockCategorySql
$itemCodeSql
$storeLocationSql
AND lot.lotId IS NOT NULL
),
opening AS (
SELECT
itemCode,
COALESCE(SUM(delta), 0) AS openingBeforeStart
FROM base
WHERE trnDateRaw < :reportPeriodStart
GROUP BY itemCode
),
period AS (
SELECT
b.*,
COALESCE(o.openingBeforeStart, 0) AS openingBeforeStart
FROM base b
LEFT JOIN opening o
ON o.itemCode = b.itemCode
WHERE b.trnDateRaw BETWEEN :reportPeriodStart AND :reportPeriodEnd
)
SELECT
stockSubCategory,
itemNo,
@@ -231,7 +87,7 @@ SELECT
unitOfMeasure,
lotNo,
expiryDate,
trnDate,
trnDate,
CASE trnRefNoRaw
WHEN 'OPEN' THEN '開倉'
WHEN 'NOR' THEN '出入倉'
@@ -255,39 +111,171 @@ SELECT
reOrderLevel,
reOrderQty,

-- jrxml 需要 String;負數括號顯示,無小數
CASE WHEN COALESCE(inQty, 0) < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(COALESCE(inQty, 0), 0) END AS stockIn,
CASE WHEN COALESCE(outQty, 0) < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(COALESCE(outQty, 0), 0) END AS stockOut,

-- 累計存量(跨 lot:只用 itemCode 分區)
CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)) < 0
THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)), 0), ')')
ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId), 0) END AS cumBalance,

-- 累計期初存量 = 本行累計 - 本行異動
CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta) < 0
THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta), 0), ')')
ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta, 0) END AS cumOpeningBal,

-- footer totals(同樣輸出 String)
CASE WHEN COALESCE(openingBeforeStart, 0) < 0 THEN CONCAT('(', FORMAT(-openingBeforeStart, 0), ')') ELSE FORMAT(COALESCE(openingBeforeStart, 0), 0) END AS totalCumOpeningBal,
CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockIn,
CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockOut,
CASE WHEN (openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)) < 0
THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)), 0), ')')
ELSE FORMAT(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode), 0) END AS totalCumBalance

FROM period
CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn,
CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut,

CASE WHEN cumOpeningBalRaw < 0
THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')')
ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal,
CASE WHEN bal < 0
THEN CONCAT('(', FORMAT(-bal, 0), ')')
ELSE FORMAT(bal, 0) END AS cumBalance,

CASE WHEN totalCumOpeningBalRaw < 0
THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')')
ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal,
CASE WHEN totalStockInRaw < 0
THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')')
ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn,
CASE WHEN totalStockOutRaw < 0
THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')')
ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut,
CASE WHEN totalCumBalanceRaw < 0
THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')')
ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance

FROM (
SELECT
x.*,
FIRST_VALUE(cumOpeningBalRaw) OVER (
PARTITION BY itemCode ORDER BY trnDateRaw, slId
) AS totalCumOpeningBalRaw,
SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw,
SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw,
FIRST_VALUE(bal) OVER (
PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC
) AS totalCumBalanceRaw
FROM (
SELECT
sl.id AS slId,
DATE(sl.date) AS trnDateRaw,
DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate,
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE'
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ'
AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE'
ELSE COALESCE(sl.type, '')
END AS trnRefNoRaw,

sl.itemCode AS itemCode,
COALESCE(sl.inQty, 0) AS inQty,
COALESCE(sl.outQty, 0) AS outQty,
COALESCE(sl.balance, 0) AS bal,
COALESCE(sl.balance, 0)
- COALESCE(sl.inQty, 0)
+ COALESCE(sl.outQty, 0) AS cumOpeningBalRaw,

it.type AS stockSubCategory,
it.code AS itemNo,
it.name AS itemName,
uc.udfudesc AS unitOfMeasure,

lot.lotNo AS lotNo,
COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
lot.storeLocation AS storeLocation,

COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo,
COALESCE(TRIM(jo.code), '') AS jobOrderNo,

'' AS openingBalance,
'' AS cumStockIn,
'' AS cumStockOut,
'' AS currentBalance,
'' AS lastInDate,
'' AS lastOutDate,
'' AS reOrderLevel,
'' AS reOrderQty
FROM stock_ledger sl
LEFT JOIN stock_in_line sil
ON sl.stockInLineId = sil.id
AND sil.deleted = 0
LEFT JOIN inventory_lot il_in
ON sil.inventoryLotId = il_in.id
AND il_in.deleted = 0

LEFT JOIN stock_out_line sol
ON sl.stockOutLineId = sol.id
AND sol.deleted = 0
LEFT JOIN inventory_lot_line ill_out
ON sol.inventoryLotLineId = ill_out.id
AND ill_out.deleted = 0
LEFT JOIN inventory_lot il_out
ON ill_out.inventoryLotId = il_out.id
AND il_out.deleted = 0

LEFT JOIN (
SELECT
il.id AS lotId,
il.lotNo AS lotNo,
il.expiryDate AS expiryDate,
MAX(wh.code) AS storeLocation
FROM inventory_lot il
LEFT JOIN inventory_lot_line ill
ON ill.inventoryLotId = il.id
AND ill.deleted = 0
LEFT JOIN warehouse wh
ON ill.warehouseId = wh.id
AND wh.deleted = 0
WHERE il.deleted = 0
GROUP BY
il.id, il.lotNo, il.expiryDate
) lot
ON lot.lotId = COALESCE(il_in.id, il_out.id)

LEFT JOIN items it
ON sl.itemId = it.id
AND it.deleted = 0
LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id

LEFT JOIN stock_out so
ON sol.stockOutId = so.id
AND so.deleted = 0
LEFT JOIN pick_order_line pol
ON sol.pickOrderLineId = pol.id
AND pol.deleted = 0
LEFT JOIN pick_order po_out
ON pol.poId = po_out.id
AND po_out.deleted = 0
LEFT JOIN job_order jo_po
ON po_out.joId = jo_po.id
AND jo_po.deleted = 0
LEFT JOIN delivery_order do
ON po_out.doId = do.id
AND do.deleted = 0
LEFT JOIN stock_in si
ON sil.stockInId = si.id
AND si.deleted = 0
LEFT JOIN job_order jo
ON sil.jobOrderId = jo.id
AND jo.deleted = 0
LEFT JOIN purchase_order po
ON sil.purchaseOrderId = po.id
AND po.deleted = 0
WHERE
sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
AND sl.date >= :reportPeriodStart
AND sl.date < :reportPeriodEndExclusive
$stockCategorySql
$itemCodeSql
$storeLocationSql
AND lot.lotId IS NOT NULL
) x
) y
ORDER BY
itemNo,
trnDateRaw,
slId,
lotNo
""".trimIndent()
val result = jdbcDao.queryForList(sql, args)

return result
return jdbcDao.queryForList(sql, args)
}

/** LIKE 多值工具方法 */
@@ -327,4 +315,4 @@ ORDER BY
}
return "AND (${conditions.joinToString(" OR ")})"
}
}
}

+ 419
- 0
src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt Voir le fichier

@@ -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 ")})"
}
}

+ 116
- 0
src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt Voir le fichier

@@ -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()
}
}

+ 11
- 5
src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt Voir le fichier

@@ -21,6 +21,7 @@ class ItemQcFailReportController(
private val itemQcFailReportService: ItemQcFailReportService,
) {

/** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */
@GetMapping("/print-item-qc-fail")
fun generateItemQcFailReport(
@RequestParam(required = false) stockCategory: String?,
@@ -29,7 +30,7 @@ class ItemQcFailReportController(
@RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) qcType: String?,
@RequestParam(required = false, defaultValue = "true") includeMeasurable: String?,
@RequestParam(required = false, defaultValue = "false") includeOther: String?,
@RequestParam(required = false, defaultValue = "true") includeOther: String?,
@RequestParam(required = false, defaultValue = "all") measurableScope: String?,
): ResponseEntity<ByteArray> {
val dbData = itemQcFailReportService.searchItemQcFailReport(
@@ -70,6 +71,7 @@ class ItemQcFailReportController(
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */
@GetMapping("/print-item-qc-fail-excel")
fun exportItemQcFailReportExcel(
@RequestParam(required = false) stockCategory: String?,
@@ -78,7 +80,7 @@ class ItemQcFailReportController(
@RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) qcType: String?,
@RequestParam(required = false, defaultValue = "true") includeMeasurable: String?,
@RequestParam(required = false, defaultValue = "false") includeOther: String?,
@RequestParam(required = false, defaultValue = "true") includeOther: String?,
@RequestParam(required = false, defaultValue = "all") measurableScope: String?,
): ResponseEntity<ByteArray> {
val dbData = itemQcFailReportService.searchItemQcFailReport(
@@ -297,7 +299,7 @@ class ItemQcFailReportController(
"不合格數量",
"實測值",
"備註",
"訂單/工單"
"訂單/工單",
)

run {
@@ -321,12 +323,16 @@ class ItemQcFailReportController(

fun writeNumber(col: Int, value: Any?) {
val raw = value?.toString()?.trim() ?: ""
val cleaned = raw.removeSuffix(".")
// SQL FORMAT() may emit thousand separators (e.g. "1,021") — strip before parse.
val cleaned = raw
.replace(",", "")
.replace(" ", "")
.removeSuffix(".")
val bd = cleaned.toBigDecimalOrNull()
val cell = row.createCell(col)

if (bd == null) {
cell.setCellValue(cleaned)
cell.setCellValue(raw)
cell.cellStyle = textStyle
} else {
val stripped = bd.stripTrailingZeros()


+ 201
- 172
src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt Voir le fichier

@@ -14,10 +14,10 @@ import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Row
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.apache.poi.xssf.streaming.SXSSFWorkbook
import java.io.ByteArrayOutputStream

@RestController
@@ -27,66 +27,66 @@ class StockLedgerReportController(
private val stockLedgerReportService: StockLedgerReportService,
) {
private data class ExcelStyles(
val title: XSSFCellStyle,
val subtitle: XSSFCellStyle,
val header: XSSFCellStyle,
val text: XSSFCellStyle,
val center: XSSFCellStyle,
val int: XSSFCellStyle,
val dash: XSSFCellStyle,
val sumQty: XSSFCellStyle,
val sumLabel: XSSFCellStyle,
val sumEmpty: XSSFCellStyle,
val sumHidden: XSSFCellStyle,
val title: CellStyle,
val subtitle: CellStyle,
val header: CellStyle,
val text: CellStyle,
val center: CellStyle,
val int: CellStyle,
val dash: CellStyle,
val sumQty: CellStyle,
val sumLabel: CellStyle,
val sumEmpty: CellStyle,
val sumHidden: CellStyle,
)

/** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */
@GetMapping("/print-stock-ledger")
fun generateStockLedgerReport(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) storeLocation: String?,
// URL 參數名仍然是 lastInDateStart / lastInDateEnd
@RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?,
@RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()

parameters["stockCategory"] = stockCategory ?: "All"
parameters["stockSubCategory"] = stockCategory ?: "All"
parameters["itemNo"] = itemCode ?: "All"
parameters["year"] = LocalDate.now().year.toString()
parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
fun generateStockLedgerReport(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) storeLocation: String?,
@RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?,
@RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()

parameters["storeLocation"] = storeLocation ?: ""
parameters["balanceFilterStart"] = ""
parameters["balanceFilterEnd"] = ""
parameters["reportPeriodStart"] = reportPeriodStart ?: ""
parameters["reportPeriodEnd"] = reportPeriodEnd ?: ""
parameters["stockCategory"] = stockCategory ?: "All"
parameters["stockSubCategory"] = stockCategory ?: "All"
parameters["itemNo"] = itemCode ?: "All"
parameters["year"] = LocalDate.now().year.toString()
parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))

parameters["storeLocation"] = storeLocation ?: ""
parameters["balanceFilterStart"] = ""
parameters["balanceFilterEnd"] = ""
parameters["reportPeriodStart"] = reportPeriodStart ?: ""
parameters["reportPeriodEnd"] = reportPeriodEnd ?: ""

val dbData = stockLedgerReportService.searchStockLedgerReport(
stockCategory = stockCategory,
itemCode = itemCode,
storeLocation = storeLocation,
reportPeriodStart = reportPeriodStart,
reportPeriodEnd = reportPeriodEnd,
)
val dbData = stockLedgerReportService.searchStockLedgerReport(
stockCategory = stockCategory,
itemCode = itemCode,
storeLocation = storeLocation,
reportPeriodStart = reportPeriodStart,
reportPeriodEnd = reportPeriodEnd,
)

val pdfBytes = reportService.createPdfResponse(
"/jasper/StockLedgarReport.jrxml",
parameters,
dbData
)
val pdfBytes = reportService.createPdfResponse(
"/jasper/StockLedgarReport.jrxml",
parameters,
dbData,
)

val headers = HttpHeaders().apply {
contentType = MediaType.APPLICATION_PDF
setContentDispositionFormData("attachment", "StockLedgerReport.pdf")
set("filename", "StockLedgerReport.pdf")
val headers = HttpHeaders().apply {
contentType = MediaType.APPLICATION_PDF
setContentDispositionFormData("attachment", "StockLedgerReport.pdf")
set("filename", "StockLedgerReport.pdf")
}
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */
@GetMapping("/print-stock-ledger-excel")
fun exportStockLedgerReportExcel(
@RequestParam(required = false) stockCategory: String?,
@@ -119,8 +119,8 @@ fun generateStockLedgerReport(
return ResponseEntity(excelBytes, headers, HttpStatus.OK)
}

private fun createStyles(workbook: XSSFWorkbook): ExcelStyles {
val titleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
private fun createStyles(workbook: Workbook): ExcelStyles {
val titleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
@@ -129,7 +129,7 @@ fun generateStockLedgerReport(
}
setFont(font)
}
val subtitleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val subtitleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
@@ -138,7 +138,7 @@ fun generateStockLedgerReport(
}
setFont(font)
}
val headerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val headerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
@@ -150,7 +150,7 @@ fun generateStockLedgerReport(
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val textStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val textStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
@@ -158,7 +158,7 @@ fun generateStockLedgerReport(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val centerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val centerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
@@ -166,7 +166,7 @@ fun generateStockLedgerReport(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val intStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val intStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
@@ -176,7 +176,7 @@ fun generateStockLedgerReport(
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
}
val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val dashStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
@@ -184,7 +184,7 @@ fun generateStockLedgerReport(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val sumQty = (workbook.createCellStyle() as XSSFCellStyle).apply {
val sumQty = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
val df: DataFormat = workbook.createDataFormat()
@@ -196,7 +196,7 @@ fun generateStockLedgerReport(
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val sumLabel = (workbook.createCellStyle() as XSSFCellStyle).apply {
val sumLabel = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
@@ -206,7 +206,7 @@ fun generateStockLedgerReport(
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val sumEmpty = (workbook.createCellStyle() as XSSFCellStyle).apply {
val sumEmpty = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
@@ -214,7 +214,7 @@ fun generateStockLedgerReport(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val sumHidden = (workbook.createCellStyle() as XSSFCellStyle).apply {
val sumHidden = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
@@ -239,7 +239,7 @@ fun generateStockLedgerReport(
)
}

private fun setTextCell(row: Row, col: Int, value: Any?, style: XSSFCellStyle) {
private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) {
row.createCell(col).apply {
setCellValue(value?.toString() ?: "")
cellStyle = style
@@ -259,8 +259,8 @@ fun generateStockLedgerReport(
row: Row,
col: Int,
value: Any?,
intStyle: XSSFCellStyle,
dashStyle: XSSFCellStyle,
intStyle: CellStyle,
dashStyle: CellStyle,
) {
val cell = row.createCell(col)
val parsed = parseSignedNumber(value)
@@ -279,131 +279,160 @@ fun generateStockLedgerReport(
}
}

/**
* SXSSF keeps only a sliding window of rows in memory to avoid OOM on large exports.
*/
private fun createStockLedgerExcel(
dbData: List<Map<String, Any>>,
reportPeriodStart: String,
reportPeriodEnd: String,
): ByteArray {
val workbook = XSSFWorkbook()
val styles = createStyles(workbook)
val reportTitle = "庫存明細報告"
val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle))

val headers = listOf(
"貨品編號", "貨品名稱", "單位",
"出入賬日期", "類型", "批號", "到期日",
"纍計期初存量", "入庫", "出庫", "纍計存量",
"參考編號", "存貨位置",
)
val totalColumns = headers.size
var rowIndex = 0
val workbook = SXSSFWorkbook(100)
workbook.setCompressTempFiles(true)
try {
val styles = createStyles(workbook)
val reportTitle = "庫存明細報告"
val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle))

val titleRow = sheet.createRow(rowIndex++)
titleRow.createCell(0).apply {
setCellValue(reportTitle)
cellStyle = styles.title
}
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

val reportDateTime =
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) +
"(" +
LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) +
")"
val subtitleRow = sheet.createRow(rowIndex++)
subtitleRow.createCell(0).apply {
setCellValue("報告日期:$reportDateTime")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4))
subtitleRow.createCell(5).apply {
setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1))
sheet.createRow(rowIndex++)
val headers = listOf(
"貨品編號", "貨品名稱", "單位",
"出入賬日期", "類型", "批號", "到期日",
"纍計期初存量", "入庫", "出庫", "纍計存量",
"參考編號", "存貨位置",
)
val totalColumns = headers.size
var rowIndex = 0

val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
val titleRow = sheet.createRow(rowIndex++)
titleRow.createCell(0).apply {
setCellValue(reportTitle)
cellStyle = styles.title
}
}
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

fun addItemSummaryRow(itemNo: String, itemName: String, uom: String, totalIn: Any?, totalOut: Any?, totalBal: Any?) {
val r = sheet.createRow(rowIndex++)
r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden }
r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden }
r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden }
for (c in 3 until totalColumns) {
r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty }
val reportDateTime =
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) +
"(" +
LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) +
")"
val subtitleRow = sheet.createRow(rowIndex++)
subtitleRow.createCell(0).apply {
setCellValue("報告日期:$reportDateTime")
cellStyle = styles.subtitle
}
// totals should align with numeric columns (shift right by 1)
r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel }
setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash)
setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash)
setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash)
}

if (dbData.isEmpty()) {
val r = sheet.createRow(rowIndex++)
for (c in 0 until totalColumns) {
r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text }
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4))
subtitleRow.createCell(5).apply {
setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}")
cellStyle = styles.subtitle
}
} else {
var currentItemNo: String? = null
var currentItemName = ""
var currentUom = ""
var lastTotals: Triple<Any?, Any?, Any?> = Triple(null, null, null)
sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1))
sheet.createRow(rowIndex++)

dbData.forEach { m ->
val itemNo = m["itemNo"]?.toString().orEmpty()
val itemName = m["itemName"]?.toString().orEmpty()
val uom = m["unitOfMeasure"]?.toString().orEmpty()
val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
}
}

if (currentItemNo != null && itemNo != currentItemNo) {
addItemSummaryRow(currentItemNo!!, currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third)
sheet.createRow(rowIndex++)
fun addItemSummaryRow(
itemNo: String,
itemName: String,
uom: String,
totalIn: Any?,
totalOut: Any?,
totalBal: Any?,
) {
val r = sheet.createRow(rowIndex++)
r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden }
r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden }
r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden }
for (c in 3 until totalColumns) {
r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty }
}
r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel }
setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash)
setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash)
setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash)
}

if (dbData.isEmpty()) {
val r = sheet.createRow(rowIndex++)
setTextCell(r, 0, itemNo, styles.text)
setTextCell(r, 1, itemName, styles.text)
setTextCell(r, 2, uom, styles.center)
setTextCell(r, 3, m["trnDate"], styles.center)
val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t ->
if (t.equals("Expiry", ignoreCase = true)) "過期" else t
for (c in 0 until totalColumns) {
r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text }
}
setTextCell(r, 4, typeText, styles.center)
setTextCell(r, 5, m["lotNo"], styles.text)
setTextCell(r, 6, m["expiryDate"], styles.center)
setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash)
setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash)
setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash)
setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash)
setTextCell(r, 11, m["orderRefNo"], styles.text)
setTextCell(r, 12, m["storeLocation"], styles.center)
} else {
var currentItemNo: String? = null
var currentItemName = ""
var currentUom = ""
var lastTotals: Triple<Any?, Any?, Any?> = Triple(null, null, null)

dbData.forEach { m ->
val itemNo = m["itemNo"]?.toString().orEmpty()
val itemName = m["itemName"]?.toString().orEmpty()
val uom = m["unitOfMeasure"]?.toString().orEmpty()

currentItemNo = itemNo
currentItemName = itemName
currentUom = uom
lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"])
if (currentItemNo != null && itemNo != currentItemNo) {
addItemSummaryRow(
currentItemNo!!,
currentItemName,
currentUom,
lastTotals.first,
lastTotals.second,
lastTotals.third,
)
sheet.createRow(rowIndex++)
}

val r = sheet.createRow(rowIndex++)
setTextCell(r, 0, itemNo, styles.text)
setTextCell(r, 1, itemName, styles.text)
setTextCell(r, 2, uom, styles.center)
setTextCell(r, 3, m["trnDate"], styles.center)
val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t ->
if (t.equals("Expiry", ignoreCase = true)) "過期" else t
}
setTextCell(r, 4, typeText, styles.center)
setTextCell(r, 5, m["lotNo"], styles.text)
setTextCell(r, 6, m["expiryDate"], styles.center)
setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash)
setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash)
setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash)
setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash)
setTextCell(r, 11, m["orderRefNo"], styles.text)
setTextCell(r, 12, m["storeLocation"], styles.center)

currentItemNo = itemNo
currentItemName = itemName
currentUom = uom
lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"])
}

addItemSummaryRow(
currentItemNo ?: "",
currentItemName,
currentUom,
lastTotals.first,
lastTotals.second,
lastTotals.third,
)
}

addItemSummaryRow(currentItemNo ?: "", currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third)
}
val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0))
}
val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0))
val out = ByteArrayOutputStream()
workbook.write(out)
return out.toByteArray()
} finally {
workbook.dispose()
workbook.close()
}
val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.use { it.write(out) }
return out.toByteArray()
}
}
}

+ 321
- 0
src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt Voir le fichier

@@ -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()
}
}
}

+ 11
- 11
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Voir le fichier

@@ -1615,6 +1615,9 @@ open class StockTakeRecordService(
)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 70 | v1.0.0 | 2026-08-31
*/
open fun checkAndUpdateStockTakeStatus(stockTakeId: Long, stockTakeSection: String): Map<String, Any> {
try {
val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId)
@@ -1648,18 +1651,17 @@ open class StockTakeRecordService(
}
}

// 輪次預建後:以本 section 本輪「全部記錄是否已完成第一次盤點」為準(不再與即時庫存行逐行對齊)
val allLinesHaveRecords = stockTakeRecords.isNotEmpty() &&
stockTakeRecords.all { it.pickerFirstStockTakeQty != null }

// 以本 section 本輪「列狀態」升單頭。審核員單筆先核(無第一次數量)只要已是
// pass/completed 即算完成,不再要求每一筆都有 pickerFirstStockTakeQty。
val allRecordsPassed = stockTakeRecords.isNotEmpty() &&
stockTakeRecords.all { it.status == "pass" || it.status == "completed" }
val allRecordsCompleted = stockTakeRecords.isNotEmpty() &&
stockTakeRecords.all { it.status == "completed" }
// 6. 如果所有记录都已创建且都是 "pass",更新 stock take 状态为 "approving"
if (allLinesHaveRecords && allRecordsCompleted) {
val now = java.time.LocalDateTime.now()
if (allRecordsCompleted) {
stockTake.status = StockTakeStatus.COMPLETED
stockTake.planEnd = java.time.LocalDateTime.now()
stockTake.planEnd = now
stockTake.actualEnd = now
stockTakeRepository.save(stockTake)
println("Stock take $stockTakeId status updated to COMPLETED - all records are completed")
return mapOf(
@@ -1667,10 +1669,9 @@ open class StockTakeRecordService(
"message" to "Stock take status updated to COMPLETED",
"updated" to true
)
} else if (allLinesHaveRecords && allRecordsPassed) {
// 如果所有记录都已创建且都是 "pass" 或 "completed",更新 stock take 状态为 "approving"
} else if (allRecordsPassed) {
stockTake.status = StockTakeStatus.APPROVING
stockTake.actualEnd = java.time.LocalDateTime.now()
stockTake.actualEnd = now
stockTakeRepository.save(stockTake)

println("Stock take $stockTakeId status updated to APPROVING - all records are pass")
@@ -1684,7 +1685,6 @@ open class StockTakeRecordService(
"success" to true,
"message" to "Conditions not met for status update",
"updated" to false,
"allLinesHaveRecords" to allLinesHaveRecords,
"allRecordsPassed" to allRecordsPassed,
"allRecordsCompleted" to allRecordsCompleted
)


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java Voir le fichier

@@ -13,6 +13,8 @@ import com.ffii.fpsms.modules.user.entity.projections.UserCombo;
public interface UserRepository extends AbstractRepository<User, Long> {

List<User> findByName(@Param("name") String name);

List<User> findByNameAndDeletedFalse(String name);
Optional<User> findByUsernameAndDeletedFalse(String username);

@@ -20,6 +22,8 @@ public interface UserRepository extends AbstractRepository<User, Long> {

Optional<User> findByStaffNo(@Param("staffNo") String staffNo);

Optional<User> findByStaffNoAndDeletedFalse(String staffNo);

@Modifying
@Query(value = """
INSERT INTO user_authority (userID, authId)


+ 23
- 0
src/main/java/com/ffii/fpsms/modules/user/service/UserService.java Voir le fichier

@@ -92,6 +92,22 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos
return userRepository.findByUsernameAndDeletedFalse(username);
}

/** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */
public boolean isNameTaken(String name) {
if (StringUtils.isBlank(name)) {
return false;
}
return !userRepository.findByNameAndDeletedFalse(name).isEmpty();
}

/** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */
public boolean isStaffNoTaken(String staffNo) {
if (StringUtils.isBlank(staffNo)) {
return false;
}
return userRepository.findByStaffNoAndDeletedFalse(staffNo).isPresent();
}

// @Transactional(rollbackFor = Exception.class)
public List<UserRecord> search(SearchUserReq req) {
StringBuilder sql = new StringBuilder("SELECT"
@@ -199,11 +215,18 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos
return instance;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */
@Transactional(rollbackFor = Exception.class)
public User newRecord(NewUserReq req) throws UnsupportedEncodingException {
if (findByUsername(req.getUsername()).isPresent()) {
throw new UnprocessableEntityException(ErrorCodes.USERNAME_NOT_AVAILABLE);
}
if (isNameTaken(req.getName())) {
throw new UnprocessableEntityException(ErrorCodes.NAME_NOT_AVAILABLE);
}
if (isStaffNoTaken(req.getStaffNo())) {
throw new UnprocessableEntityException(ErrorCodes.STAFF_NO_NOT_AVAILABLE);
}

System.out.println("Start Save");



+ 17
- 1
src/main/java/com/ffii/fpsms/py/PyController.kt Voir le fichier

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

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


+ 7
- 0
src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt Voir le fichier

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

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

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

+ 26
- 0
src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt Voir le fichier

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

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

object PyJobOrderListMapper {

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

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

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

+ 8
- 0
src/main/resources/db/changelog/changes/20260813_m18_sync_authority/01_add_m18_sync_authority.sql Voir le fichier

@@ -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');

+ 21
- 0
src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql Voir le fichier

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

--changeset fpsms:create_item_default_shelf_life
--comment: Default (non -18 warehouse) shelf life days by item code for bag / OnPack expiry print
CREATE TABLE `item_default_shelf_life`
(
`id` INT NOT NULL AUTO_INCREMENT,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`createdBy` VARCHAR(30) NULL DEFAULT NULL,
`version` INT NOT NULL DEFAULT '0',
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modifiedBy` VARCHAR(30) NULL DEFAULT NULL,
`deleted` TINYINT(1) NOT NULL DEFAULT '0',
`itemCode` VARCHAR(50) NOT NULL,
`defaultDays` INT NOT NULL COMMENT 'Main shelf life days; not -18 warehouse',
`openedDays` INT NULL COMMENT '0-4C secondary; unused for print for now',
`storageC` VARCHAR(20) NULL,
`remarks` VARCHAR(255) NULL,
CONSTRAINT pk_item_default_shelf_life PRIMARY KEY (`id`),
UNIQUE KEY uk_item_default_shelf_life_itemCode (`itemCode`)
);

+ 17
- 0
src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql Voir le fichier

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

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

+ 181
- 0
src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql Voir le fichier

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

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

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

+ 8
- 0
src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql Voir le fichier

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

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

+ 220
- 0
src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql Voir le fichier

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

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

+ 21
- 0
src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql Voir le fichier

@@ -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`)
);

+ 18
- 0
src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql Voir le fichier

@@ -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`)
);

+ 44
- 0
src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql Voir le fichier

@@ -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');

+ 6
- 0
src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql Voir le fichier

@@ -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`;

BIN
src/main/resources/excelTemplate/bom_import_blank.xlsx Voir le fichier


+ 1
- 1
src/main/resources/jasper/StockLedgarReport.jrxml Voir le fichier

@@ -79,7 +79,7 @@
<band height="18">
<textField textAdjust="StretchHeight">
<reportElement isPrintRepeatedValues="false" x="11" y="0" width="579" height="18" uuid="5b2d2e95-26eb-4e8c-93ba-99eaed3490df"/>
<textElement textAlignment="Left" verticalAlignment="Top" markup="styled">
<textElement textAlignment="Left" verticalAlignment="Top" markup="none">
<font fontName="微軟正黑體" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{itemNo}+" "+$F{itemName}+" "+$F{unitOfMeasure}]]></textFieldExpression>


BIN
src/main/resources/onpack2030_exp/pp1181.image Voir le fichier


+ 66
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt Voir le fichier

@@ -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(" "))
}
}

+ 109
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt Voir le fichier

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

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

class OnPackJuiceExpiryXmlTest {

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

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

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

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

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

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

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

+ 40
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt Voir le fichier

@@ -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>"))
}
}

+ 42
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt Voir le fichier

@@ -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))
}
}

+ 87
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt Voir le fichier

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

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

class OnPackTemplateFileServiceTest {

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

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

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

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

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

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

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

+ 206
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt Voir le fichier

@@ -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()
}

+ 67
- 0
src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt Voir le fichier

@@ -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",
)
}
}
}

+ 94
- 0
src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt Voir le fichier

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

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

class ItemM18IdRemapSupportTest {

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

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

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

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

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

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

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

+ 36
- 10
src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt Voir le fichier

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

class PyJobOrderListMapperTest {

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

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

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

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

Chargement…
Annuler
Enregistrer