FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

WorkbenchGoodPickExecutionDetail.tsx 175 KiB

​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
4 months ago
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
3 months ago
​
​
​
​
3 months ago
​
3 months ago
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734
  1. "use client";
  2. import {
  3. Box,
  4. Button,
  5. Stack,
  6. TextField,
  7. Typography,
  8. Alert,
  9. CircularProgress,
  10. Table,
  11. TableBody,
  12. TableCell,
  13. TableContainer,
  14. TableHead,
  15. TableRow,
  16. Paper,
  17. Checkbox,
  18. TablePagination,
  19. Modal,
  20. Chip,
  21. } from "@mui/material";
  22. import dayjs from 'dayjs';
  23. import { normalizeTargetDateInput } from "@/utils/workbenchTargetDate";
  24. import TestQrCodeProvider from "@/components/QrCodeScannerProvider/TestQrCodeProvider";
  25. import { fetchLotDetail } from "@/app/api/inventory/actions";
  26. import React, { useCallback, useEffect, useState, useRef, useMemo, startTransition } from "react";
  27. import { useTranslation } from "react-i18next";
  28. import { usePathname, useRouter } from "next/navigation";
  29. import {
  30. updateStockOutLineStatus,
  31. createStockOutLine,
  32. updateStockOutLine,
  33. recordPickExecutionIssue,
  34. fetchFGPickOrders, // Add this import
  35. FGPickOrderResponse,
  36. stockReponse,
  37. PickExecutionIssueData,
  38. checkPickOrderCompletion,
  39. fetchAllPickOrderLotsHierarchicalWorkbench,
  40. PickOrderCompletionResponse,
  41. updateSuggestedLotLineId,
  42. updateStockOutLineStatusByQRCodeAndLotNo,
  43. confirmLotSubstitution,
  44. fetchDoPickOrderDetail, // 必须添加
  45. DoPickOrderDetail, // 必须添加
  46. batchScan,
  47. BatchScanRequest,
  48. BatchScanLineRequest,
  49. } from "@/app/api/pickOrder/actions";
  50. import { workbenchBatchScanPick, workbenchScanPick } from "@/app/api/doworkbench/actions";
  51. import { workbenchScanPickResponseNeedsFullRefresh } from "@/app/api/doworkbench/workbenchScanPickUtils";
  52. import FGPickOrderInfoCard from "@/components/FinishedGoodSearch/FGPickOrderInfoCard";
  53. //import { fetchItem } from "@/app/api/settings/item";
  54. import { updateInventoryLotLineStatus, analyzeQrCode } from "@/app/api/inventory/actions";
  55. import { fetchNameList, NameList } from "@/app/api/user/actions";
  56. import {
  57. FormProvider,
  58. useForm,
  59. } from "react-hook-form";
  60. import SearchBox, { Criterion } from "@/components/SearchBox";
  61. import { CreateStockOutLine } from "@/app/api/pickOrder/actions";
  62. import { updateInventoryLotLineQuantities } from "@/app/api/inventory/actions";
  63. import QrCodeIcon from '@mui/icons-material/QrCode';
  64. import { useQrCodeScannerContext } from "@/components/QrCodeScannerProvider/QrCodeScannerProvider";
  65. import { useSession } from "next-auth/react";
  66. import { SessionWithTokens } from "@/config/authConfig";
  67. import { fetchStockInLineInfo } from "@/app/api/po/actions";
  68. import GoodPickExecutionForm from "@/components/FinishedGoodSearch/GoodPickExecutionForm";
  69. import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal";
  70. import FGPickOrderCard from "@/components/FinishedGoodSearch/FGPickOrderCard";
  71. import LinearProgressWithLabel from "@/components/common/LinearProgressWithLabel";
  72. import ScanStatusAlert from "@/components/common/ScanStatusAlert";
  73. import Swal from "sweetalert2";
  74. import { printDNLabelsWorkbench } from "@/app/api/do/actions";
  75. import type { PrinterCombo } from "@/app/api/settings/printer";
  76. interface Props {
  77. filterArgs: Record<string, any>;
  78. onSwitchToRecordTab?: () => void;
  79. onRefreshReleasedOrderCount?: () => void;
  80. /** 階層揀貨資料已無(例如訂單已完成/釋放)時通知外層,以便重新檢查是否仍為「已指派」狀態 */
  81. onWorkbenchHierarchyEmpty?: () => void;
  82. labelPrinter?: PrinterCombo | null;
  83. }
  84. /** 同物料多行时,优先对「有建议批次号」的行做替换,避免误选「无批次/不足」行 */
  85. /** itemId → Set of processed stockOutLineId (allow same physical lot QR for next SOL). */
  86. type ProcessedStockOutLinesByItemId = Map<number, Set<number>>;
  87. function isLotRowPending(lot: any): boolean {
  88. const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
  89. return (
  90. st === "pending" ||
  91. st === "partially_completed" ||
  92. st === "partially_complete" ||
  93. st === ""
  94. );
  95. }
  96. function isStockOutLineAlreadyProcessed(
  97. processedByItemId: ProcessedStockOutLinesByItemId,
  98. itemId: number,
  99. stockOutLineId: number | null | undefined,
  100. ): boolean {
  101. const solId = Number(stockOutLineId);
  102. if (!Number.isFinite(solId) || solId <= 0) return false;
  103. return processedByItemId.get(itemId)?.has(solId) ?? false;
  104. }
  105. function markProcessedStockOutLine(
  106. prev: ProcessedStockOutLinesByItemId,
  107. itemId: number,
  108. stockOutLineId: number | null | undefined,
  109. ): ProcessedStockOutLinesByItemId {
  110. const solId = Number(stockOutLineId);
  111. if (!Number.isFinite(solId) || solId <= 0) return prev;
  112. const newMap = new Map(prev);
  113. if (!newMap.has(itemId)) newMap.set(itemId, new Set());
  114. newMap.get(itemId)!.add(solId);
  115. return newMap;
  116. }
  117. /** Parse workbench QR / test shortcut into itemId + stockInLineId. */
  118. function parseWorkbenchScanQrPayload(
  119. raw: string,
  120. ): { itemId: number; stockInLineId: number } | null {
  121. const text = String(raw || "").trim();
  122. if (!text) return null;
  123. if (
  124. (text.startsWith("{2fitest") || text.startsWith("{2fittest")) &&
  125. text.endsWith("}")
  126. ) {
  127. let content = "";
  128. if (text.startsWith("{2fittest")) {
  129. content = text.substring(9, text.length - 1);
  130. } else {
  131. content = text.substring(8, text.length - 1);
  132. }
  133. const parts = content.split(",");
  134. if (parts.length === 2) {
  135. const itemId = parseInt(parts[0].trim(), 10);
  136. const stockInLineId = parseInt(parts[1].trim(), 10);
  137. if (
  138. Number.isFinite(itemId) &&
  139. itemId > 0 &&
  140. Number.isFinite(stockInLineId) &&
  141. stockInLineId > 0
  142. ) {
  143. return { itemId, stockInLineId };
  144. }
  145. }
  146. return null;
  147. }
  148. try {
  149. const obj = JSON.parse(text);
  150. const itemId = Number(obj?.itemId);
  151. const stockInLineId = Number(obj?.stockInLineId);
  152. if (
  153. Number.isFinite(itemId) &&
  154. itemId > 0 &&
  155. Number.isFinite(stockInLineId) &&
  156. stockInLineId > 0
  157. ) {
  158. return { itemId, stockInLineId };
  159. }
  160. } catch {
  161. /* not JSON */
  162. }
  163. return null;
  164. }
  165. function hasPendingActiveRowForStockInLine(
  166. indexes: {
  167. byStockInLineId: Map<number, any[]>;
  168. activeLotsByItemId: Map<number, any[]>;
  169. },
  170. itemId: number,
  171. stockInLineId: number,
  172. processedByItemId: ProcessedStockOutLinesByItemId,
  173. ): boolean {
  174. const rows = indexes.byStockInLineId.get(stockInLineId) ?? [];
  175. const activeSet = new Set(indexes.activeLotsByItemId.get(itemId) ?? []);
  176. return rows.some(
  177. (lot) =>
  178. lot.itemId === itemId &&
  179. activeSet.has(lot) &&
  180. isLotRowPending(lot) &&
  181. !isStockOutLineAlreadyProcessed(processedByItemId, itemId, lot.stockOutLineId),
  182. );
  183. }
  184. /**
  185. * True when this item still has a pending SOL that scan can bind to — including
  186. * noLot / expired / unavailable rows (those are excluded from activeLotsByItemId).
  187. * Used so a brand-new stockInLineId can still reach auto-switch.
  188. */
  189. function hasPendingSwitchableRowForItem(
  190. indexes: { byItemId: Map<number, any[]> },
  191. itemId: number,
  192. processedByItemId: ProcessedStockOutLinesByItemId,
  193. ): boolean {
  194. const rows = indexes.byItemId.get(itemId) ?? [];
  195. return rows.some(
  196. (lot) =>
  197. isLotRowPending(lot) &&
  198. !isStockOutLineAlreadyProcessed(processedByItemId, itemId, lot.stockOutLineId),
  199. );
  200. }
  201. /** Allow scan when scanned SIL still needed, or item has any pending/noLot row for auto-switch. */
  202. function canProcessWorkbenchScanPick(
  203. indexes: {
  204. byStockInLineId: Map<number, any[]>;
  205. activeLotsByItemId: Map<number, any[]>;
  206. byItemId: Map<number, any[]>;
  207. },
  208. itemId: number,
  209. stockInLineId: number,
  210. processedByItemId: ProcessedStockOutLinesByItemId,
  211. ): boolean {
  212. return (
  213. hasPendingActiveRowForStockInLine(
  214. indexes,
  215. itemId,
  216. stockInLineId,
  217. processedByItemId,
  218. ) || hasPendingSwitchableRowForItem(indexes, itemId, processedByItemId)
  219. );
  220. }
  221. function findExactActiveMatchForStockInLine(
  222. stockInLineLots: any[],
  223. scannedItemId: number,
  224. activeSuggestedLots: any[],
  225. processedByItemId: ProcessedStockOutLinesByItemId,
  226. ): any | null {
  227. const activeSet = new Set(activeSuggestedLots);
  228. const candidates = stockInLineLots.filter(
  229. (lot) =>
  230. lot.itemId === scannedItemId &&
  231. activeSet.has(lot) &&
  232. !isStockOutLineAlreadyProcessed(
  233. processedByItemId,
  234. scannedItemId,
  235. lot.stockOutLineId,
  236. ),
  237. );
  238. if (candidates.length === 0) return null;
  239. return candidates.find((lot) => isLotRowPending(lot)) ?? candidates[0];
  240. }
  241. function pickExpectedLotForSubstitution(
  242. activeSuggestedLots: any[],
  243. processedByItemId?: ProcessedStockOutLinesByItemId,
  244. ): any | null {
  245. if (!activeSuggestedLots?.length) return null;
  246. const itemId = activeSuggestedLots[0]?.itemId;
  247. const processed = processedByItemId ?? new Map();
  248. const unprocessed = activeSuggestedLots.filter(
  249. (l) =>
  250. !itemId ||
  251. !isStockOutLineAlreadyProcessed(processed, itemId, l.stockOutLineId),
  252. );
  253. const pool = unprocessed.length > 0 ? unprocessed : activeSuggestedLots;
  254. const withLotNo = pool.filter(
  255. (l) => l.lotNo != null && String(l.lotNo).trim() !== "",
  256. );
  257. const searchPool = withLotNo.length > 0 ? withLotNo : pool;
  258. if (searchPool.length === 1) return searchPool[0];
  259. const pending = searchPool.find((l) => isLotRowPending(l));
  260. return pending ?? searchPool[0];
  261. }
  262. const ManualLotConfirmationModal: React.FC<{
  263. open: boolean;
  264. onClose: () => void;
  265. onConfirm: (expectedLotNo: string, scannedLotNo: string) => void;
  266. expectedLot: {
  267. lotNo: string;
  268. itemCode: string;
  269. itemName: string;
  270. } | null;
  271. scannedLot: {
  272. lotNo: string;
  273. itemCode: string;
  274. itemName: string;
  275. } | null;
  276. isLoading?: boolean;
  277. }> = ({ open, onClose, onConfirm, expectedLot, scannedLot, isLoading = false }) => {
  278. const { t } = useTranslation("pickOrder");
  279. const [expectedLotInput, setExpectedLotInput] = useState<string>('');
  280. const [scannedLotInput, setScannedLotInput] = useState<string>('');
  281. const [error, setError] = useState<string>('');
  282. // 当模态框打开时,预填充输入框
  283. useEffect(() => {
  284. if (open) {
  285. setExpectedLotInput(expectedLot?.lotNo || '');
  286. setScannedLotInput(scannedLot?.lotNo || '');
  287. setError('');
  288. }
  289. }, [open, expectedLot, scannedLot]);
  290. const handleConfirm = () => {
  291. if (!expectedLotInput.trim() || !scannedLotInput.trim()) {
  292. setError(t("Please enter both expected and scanned lot numbers."));
  293. return;
  294. }
  295. if (expectedLotInput.trim() === scannedLotInput.trim()) {
  296. setError(t("Expected and scanned lot numbers cannot be the same."));
  297. return;
  298. }
  299. onConfirm(expectedLotInput.trim(), scannedLotInput.trim());
  300. };
  301. return (
  302. <Modal open={open} onClose={onClose}>
  303. <Box sx={{
  304. position: 'absolute',
  305. top: '50%',
  306. left: '50%',
  307. transform: 'translate(-50%, -50%)',
  308. bgcolor: 'background.paper',
  309. p: 3,
  310. borderRadius: 2,
  311. minWidth: 500,
  312. }}>
  313. <Typography variant="h6" gutterBottom color="warning.main">
  314. {t("Manual Lot Confirmation")}
  315. </Typography>
  316. <Box sx={{ mb: 2 }}>
  317. <Typography variant="body2" gutterBottom>
  318. <strong>{t("Expected Lot Number")}:</strong>
  319. </Typography>
  320. <TextField
  321. fullWidth
  322. size="small"
  323. value={expectedLotInput}
  324. onChange={(e) => {
  325. setExpectedLotInput(e.target.value);
  326. setError('');
  327. }}
  328. placeholder={expectedLot?.lotNo || t("Enter expected lot number")}
  329. sx={{ mb: 2 }}
  330. error={!!error && !expectedLotInput.trim()}
  331. />
  332. </Box>
  333. <Box sx={{ mb: 2 }}>
  334. <Typography variant="body2" gutterBottom>
  335. <strong>{t("Scanned Lot Number")}:</strong>
  336. </Typography>
  337. <TextField
  338. fullWidth
  339. size="small"
  340. value={scannedLotInput}
  341. onChange={(e) => {
  342. setScannedLotInput(e.target.value);
  343. setError('');
  344. }}
  345. placeholder={scannedLot?.lotNo || t("Enter scanned lot number")}
  346. sx={{ mb: 2 }}
  347. error={!!error && !scannedLotInput.trim()}
  348. />
  349. </Box>
  350. {error && (
  351. <Box sx={{ mb: 2, p: 1, backgroundColor: '#ffebee', borderRadius: 1 }}>
  352. <Typography variant="body2" color="error">
  353. {error}
  354. </Typography>
  355. </Box>
  356. )}
  357. <Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
  358. <Button onClick={onClose} variant="outlined" disabled={isLoading}>
  359. {t("Cancel")}
  360. </Button>
  361. <Button
  362. onClick={handleConfirm}
  363. variant="contained"
  364. color="warning"
  365. disabled={isLoading || !expectedLotInput.trim() || !scannedLotInput.trim()}
  366. >
  367. {isLoading ? t("Processing...") : t("Confirm")}
  368. </Button>
  369. </Box>
  370. </Box>
  371. </Modal>
  372. );
  373. };
  374. /** 過期批號(未換有效批前):與 noLot 類似——單筆/批量預設提交量為 0,除非 Issue 改數 */
  375. function isLotAvailabilityExpired(lot: any): boolean {
  376. return String(lot?.lotAvailability || "").toLowerCase() === "expired";
  377. }
  378. /** inventory_lot_line.status = unavailable(API 可能用 lotAvailability 或 lotStatus) */
  379. function isInventoryLotLineUnavailable(lot: any): boolean {
  380. if (!lot) return false;
  381. const solSt = String(lot.stockOutLineStatus || "").toLowerCase();
  382. if (solSt === "completed" || solSt === "partially_completed") return false;
  383. if (lot.lotAvailability === "status_unavailable") return true;
  384. return String(lot.lotStatus || "").toLowerCase() === "unavailable";
  385. }
  386. /** 提貨台「列印標籤」彈窗頂部:依目前表格列判斷可提貨/已用畢/已過期等 */
  387. function isWorkbenchSourceLotExpired(lot: any): boolean {
  388. if (!lot) return false;
  389. if (isLotAvailabilityExpired(lot)) return true;
  390. if (String(lot.lotAvailability || "").toLowerCase() === "expired") return true;
  391. if (lot.expiryDate) {
  392. const d = dayjs(lot.expiryDate).startOf("day");
  393. if (d.isValid() && d.isBefore(dayjs().startOf("day"))) return true;
  394. }
  395. return false;
  396. }
  397. function getWorkbenchSourceLotStatusSummary(lot: any): {
  398. severity: "success" | "warning" | "error";
  399. text: string;
  400. } {
  401. if (!lot) {
  402. return { severity: "warning", text: "無法判斷此批號狀態" };
  403. }
  404. if (isWorkbenchSourceLotExpired(lot)) {
  405. return { severity: "error", text: "此批號狀態:已過期" };
  406. }
  407. const solSt = String(lot.stockOutLineStatus || "").toLowerCase();
  408. if (solSt === "rejected") {
  409. return { severity: "warning", text: "此出庫行:已拒絕,請改掃其他批號" };
  410. }
  411. if (solSt === "completed" || solSt === "partially_completed") {
  412. return { severity: "warning", text: "此出庫行:已完成,無需再提貨" };
  413. }
  414. /**
  415. * 無批次列:後端仍標 insufficient_stock,語意是「尚無可出庫批號」而非「已用畢」。
  416. * 與表格紅字「請檢查周圍是否有 QR 碼…」一致。
  417. */
  418. const isNoLotRow =
  419. lot.noLot === true ||
  420. !lot.lotNo ||
  421. String(lot.lotNo || "").trim() === "";
  422. if (isNoLotRow) {
  423. return {
  424. severity: "warning",
  425. text: "尚未綁定批號/無可用庫存列:請掃描週邊入庫或轉倉 QR",
  426. };
  427. }
  428. const av = String(lot.lotAvailability || "").toLowerCase();
  429. if (av === "insufficient_stock") {
  430. return { severity: "warning", text: "此批號狀態:已用畢(無剩餘庫存)" };
  431. }
  432. const avail = Number(lot.availableQty);
  433. if (lot.lotNo && Number.isFinite(avail) && avail <= 0) {
  434. return { severity: "warning", text: "此批號狀態:已用畢(可用量為 0)" };
  435. }
  436. if (isInventoryLotLineUnavailable(lot)) {
  437. return {
  438. severity: "warning",
  439. text: "此批號狀態:庫存不可用(未上架或行狀態不可用)",
  440. };
  441. }
  442. return { severity: "success", text: "此批號狀態:可提貨" };
  443. }
  444. type PickOrderT = (key: string, options?: Record<string, unknown>) => string;
  445. function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string {
  446. const msg = raw.trim();
  447. if (!msg) return msg;
  448. const expiredMatch = msg.match(/^Lot is expired \(expiry=([^)]+)\)\.?$/i);
  449. if (expiredMatch) {
  450. return t("Lot is expired (expiry={{expiry}})", {
  451. expiry: expiredMatch[1],
  452. });
  453. }
  454. return t(msg);
  455. }
  456. /**
  457. * 顯示後端拒絕原因:優先 workbench scan API 的 message(暫存於 scanRejectBySolId),
  458. * 其次階層 API 若帶 stockOutLineRejectMessage,最後依 rejected + lotAvailability 推斷(與後端語意對齊)。
  459. */
  460. function buildLotRejectDisplayMessage(
  461. lot: any,
  462. scanRejectBySolId: Record<number, string>,
  463. t: PickOrderT,
  464. ): string | undefined {
  465. const solId = Number(lot.stockOutLineId) || 0;
  466. const fromScan = solId > 0 ? scanRejectBySolId[solId]?.trim() : "";
  467. if (fromScan) return translateWorkbenchRejectMessage(fromScan, t);
  468. const fromApi =
  469. typeof lot.stockOutLineRejectMessage === "string"
  470. ? lot.stockOutLineRejectMessage.trim()
  471. : "";
  472. if (fromApi) return translateWorkbenchRejectMessage(fromApi, t);
  473. const st = String(lot.stockOutLineStatus || "").toLowerCase();
  474. const av = String(lot.lotAvailability || "").toLowerCase();
  475. const isRejected = st === "rejected" || av === "rejected";
  476. if (!isRejected) return undefined;
  477. if (isLotAvailabilityExpired(lot) || av === "expired") {
  478. return t("Rejected: lot expired or no longer valid.");
  479. }
  480. if (av === "insufficient_stock") {
  481. return t("Rejected: no remaining quantity / empty lot.");
  482. }
  483. if (isInventoryLotLineUnavailable(lot) || av === "status_unavailable") {
  484. return t("Rejected: lot unavailable or not yet putaway.");
  485. }
  486. return t("Pick was rejected. Please scan another lot or check stock.");
  487. }
  488. /** Issue「改數」未寫入 SOL,刷新/換頁後需靠 session 還原,否則 Qty will submit 會回到 req */
  489. const FG_ISSUE_PICKED_KEY = (doPickOrderId: number) =>
  490. `fpsms-fg-issuePickedQty:${doPickOrderId}`;
  491. function loadIssuePickedMap(doPickOrderId: number): Record<number, number> {
  492. if (typeof window === "undefined" || !doPickOrderId) return {};
  493. try {
  494. const raw = sessionStorage.getItem(FG_ISSUE_PICKED_KEY(doPickOrderId));
  495. if (!raw) return {};
  496. const parsed = JSON.parse(raw) as Record<string, number>;
  497. const out: Record<number, number> = {};
  498. Object.entries(parsed).forEach(([k, v]) => {
  499. const n = Number(v);
  500. if (!Number.isNaN(n)) out[Number(k)] = n;
  501. });
  502. return out;
  503. } catch {
  504. return {};
  505. }
  506. }
  507. function saveIssuePickedMap(doPickOrderId: number, map: Record<number, number>) {
  508. if (typeof window === "undefined" || !doPickOrderId) return;
  509. try {
  510. sessionStorage.setItem(FG_ISSUE_PICKED_KEY(doPickOrderId), JSON.stringify(map));
  511. } catch {
  512. // quota / private mode
  513. }
  514. }
  515. /** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.2 | 2026-08-03 */
  516. const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({
  517. filterArgs,
  518. onSwitchToRecordTab,
  519. onRefreshReleasedOrderCount,
  520. onWorkbenchHierarchyEmpty,
  521. labelPrinter = null,
  522. }) => {
  523. const workbenchMode = true;
  524. const { t } = useTranslation("pickOrder");
  525. const router = useRouter();
  526. const pathname = usePathname();
  527. const { data: session } = useSession() as { data: SessionWithTokens | null };
  528. const [doPickOrderDetail, setDoPickOrderDetail] = useState<DoPickOrderDetail | null>(null);
  529. const [selectedPickOrderId, setSelectedPickOrderId] = useState<number | null>(null);
  530. const [pickOrderSwitching, setPickOrderSwitching] = useState(false);
  531. const currentUserId = session?.id ? parseInt(session.id) : undefined;
  532. const [allLotsCompleted, setAllLotsCompleted] = useState(false);
  533. const [combinedLotData, setCombinedLotData] = useState<any[]>([]);
  534. const [combinedDataLoading, setCombinedDataLoading] = useState(false);
  535. const [originalCombinedData, setOriginalCombinedData] = useState<any[]>([]);
  536. // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required)
  537. const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({});
  538. const applyLocalStockOutLineUpdate = useCallback((
  539. stockOutLineId: number,
  540. status: string,
  541. actualPickQty?: number
  542. ) => {
  543. setCombinedLotData(prev => prev.map((lot) => {
  544. if (Number(lot.stockOutLineId) !== Number(stockOutLineId)) return lot;
  545. return {
  546. ...lot,
  547. stockOutLineStatus: status,
  548. ...(typeof actualPickQty === "number"
  549. ? { actualPickQty, stockOutLineQty: actualPickQty }
  550. : {}),
  551. };
  552. }));
  553. }, []);
  554. // 防止重复点击(Submit / Just Completed / Issue)
  555. const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({});
  556. const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext();
  557. const [qrScanInput, setQrScanInput] = useState<string>('');
  558. const [qrScanError, setQrScanError] = useState<boolean>(false);
  559. const [qrScanErrorMsg, setQrScanErrorMsg] = useState<string>('');
  560. const [qrScanSuccess, setQrScanSuccess] = useState<boolean>(false);
  561. /** Workbench scanPick 等非 SUCCESS 時後端 message,按 stockOutLineId 顯示在批號欄 */
  562. const [scanRejectMessageBySolId, setScanRejectMessageBySolId] = useState<Record<number, string>>({});
  563. const rememberWorkbenchScanReject = useCallback((stockOutLineId: number, message: string | undefined | null) => {
  564. const id = Number(stockOutLineId);
  565. const m = String(message ?? "").trim();
  566. if (!id || !m) return;
  567. setScanRejectMessageBySolId((prev) => ({ ...prev, [id]: m }));
  568. }, []);
  569. const clearWorkbenchScanReject = useCallback((stockOutLineId: number) => {
  570. const id = Number(stockOutLineId);
  571. if (!id) return;
  572. setScanRejectMessageBySolId((prev) => {
  573. if (!(id in prev)) return prev;
  574. const next = { ...prev };
  575. delete next[id];
  576. return next;
  577. });
  578. }, []);
  579. const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false);
  580. /** 使用者覆寫「可提交數量」:鍵不存在=未編輯;鍵存在且為 0=明確要送 0 */
  581. const [pickQtyData, setPickQtyData] = useState<Record<string, number>>({});
  582. /** Workbench row: false = qty TextField read-only; Edit toggles true to type qty (replaces opening issue form). */
  583. const [workbenchSubmitQtyFieldEnabledByLotKey, setWorkbenchSubmitQtyFieldEnabledByLotKey] = useState<
  584. Record<string, boolean>
  585. >({});
  586. const resolveSingleSubmitQty = useCallback(
  587. (lot: any) => {
  588. const required = Number(lot.requiredQty || lot.pickOrderLineRequiredQty || 0);
  589. const solId = Number(lot.stockOutLineId) || 0;
  590. const lotId = lot.lotId;
  591. const lotKey =
  592. Number.isFinite(solId) && solId > 0 ? `sol:${solId}` : `${lot.pickOrderLineId}-${lotId}`;
  593. const issuePicked = solId > 0 ? issuePickedQtyBySolId[solId] : undefined;
  594. if (issuePicked !== undefined && !Number.isNaN(Number(issuePicked))) {
  595. return Number(issuePicked);
  596. }
  597. const st = String(lot.stockOutLineStatus || "").toLowerCase();
  598. if (
  599. st === "completed" ||
  600. st === "partially_completed" ||
  601. st === "partially_complete"
  602. ) {
  603. return Number(lot.stockOutLineQty ?? lot.actualPickQty ?? 0);
  604. }
  605. if (Object.prototype.hasOwnProperty.call(pickQtyData, lotKey)) {
  606. const fromPick = pickQtyData[lotKey];
  607. if (!Number.isNaN(Number(fromPick))) {
  608. return Number(fromPick);
  609. }
  610. }
  611. return required;
  612. },
  613. [issuePickedQtyBySolId, pickQtyData],
  614. );
  615. const getWorkbenchQtyLotKey = useCallback((lot: any) => {
  616. const solId = Number(lot?.stockOutLineId) || 0;
  617. if (Number.isFinite(solId) && solId > 0) return `sol:${solId}`;
  618. return `${lot?.pickOrderLineId}-${lot?.lotId}`;
  619. }, []);
  620. /** Use table row from combinedLotData so pickQtyData key pickOrderLineId-lotId matches the row user edited (expectedLot from QR index may differ lotId). */
  621. const workbenchScanPickQtyFromLot = useCallback(
  622. (lot: any) => {
  623. const solId = Number(lot?.stockOutLineId);
  624. const sourceLot =
  625. Number.isFinite(solId) && solId > 0
  626. ? combinedLotData.find((r) => Number(r.stockOutLineId) === solId) ?? lot
  627. : lot;
  628. const lotKey = getWorkbenchQtyLotKey(sourceLot);
  629. const hasExplicitPickOverride = Object.prototype.hasOwnProperty.call(
  630. pickQtyData,
  631. lotKey,
  632. );
  633. const n = Number(resolveSingleSubmitQty(sourceLot));
  634. // Explicit 0 short submit must send qty=0; implicit 0 must omit qty (backend implicit fill path).
  635. if (hasExplicitPickOverride && Number.isFinite(n) && n === 0) return { qty: 0 } as const;
  636. if (!Number.isFinite(n) || n <= 0) return {};
  637. return { qty: n } as const;
  638. },
  639. [resolveSingleSubmitQty, combinedLotData, pickQtyData, getWorkbenchQtyLotKey],
  640. );
  641. const [searchQuery, setSearchQuery] = useState<Record<string, any>>({});
  642. const [paginationController, setPaginationController] = useState({
  643. pageNum: 0,
  644. pageSize: -1,
  645. });
  646. const [usernameList, setUsernameList] = useState<NameList[]>([]);
  647. const initializationRef = useRef(false);
  648. const autoAssignRef = useRef(false);
  649. /** 曾成功載入過 workbench 階層資料;避免「列表仍有單但階層暫空」時對外層重複觸發造成迴圈 */
  650. const workbenchHierarchicalReadyRef = useRef(false);
  651. /** 最後一筆 workbench 票號(階層清空或完成後仍可用於導向完成紀錄) */
  652. const lastWorkbenchTicketNoRef = useRef<string | null>(null);
  653. const lastWorkbenchTargetDateRef = useRef<string | null>(null);
  654. /** 同一筆揀貨完成後只導向「完成紀錄」分頁一次 */
  655. const workbenchFinishNavigateDoneRef = useRef(false);
  656. const formProps = useForm();
  657. const errors = formProps.formState.errors;
  658. // Add GoodPickExecutionForm states
  659. const [pickExecutionFormOpen, setPickExecutionFormOpen] = useState(false);
  660. const [selectedLotForExecutionForm, setSelectedLotForExecutionForm] = useState<any | null>(null);
  661. const [fgPickOrders, setFgPickOrders] = useState<FGPickOrderResponse[]>([]);
  662. const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false);
  663. const isExtraTicket = useMemo(() => {
  664. const ticketNo = String(fgPickOrders?.[0]?.ticketNo ?? "").trim().toUpperCase();
  665. return ticketNo.startsWith("TI-E-");
  666. }, [fgPickOrders]);
  667. const lotFloorPrefixFilter = useMemo(() => {
  668. const storeId = String(fgPickOrders?.[0]?.storeId ?? "")
  669. .trim()
  670. .toUpperCase()
  671. .replace(/\s/g, "");
  672. const floorKey = storeId.replace(/\//g, "");
  673. return floorKey ? `${floorKey}-` : "";
  674. }, [fgPickOrders]);
  675. const defaultLabelPrinterName = useMemo(() => {
  676. const storeId = String(fgPickOrders?.[0]?.storeId ?? "")
  677. .trim()
  678. .toUpperCase()
  679. .replace(/\s/g, "");
  680. const floorKey = storeId.replace(/\//g, "");
  681. if (floorKey === "2F") return "Label機 2F A+B";
  682. if (floorKey === "4F") return "Label機 4F 乾貨 C, D";
  683. return undefined;
  684. }, [fgPickOrders]);
  685. const [workbenchLotLabelModalOpen, setWorkbenchLotLabelModalOpen] =
  686. useState(false);
  687. const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] =
  688. useState<{ itemId: number; stockInLineId: number } | null>(null);
  689. const [workbenchLotLabelReminderText, setWorkbenchLotLabelReminderText] =
  690. useState<string | null>(null);
  691. const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] =
  692. useState<any | null>(null);
  693. useEffect(() => {
  694. if (!qrScanSuccess || !workbenchLotLabelModalOpen) return;
  695. setWorkbenchLotLabelModalOpen(false);
  696. setWorkbenchLotLabelInitialPayload(null);
  697. setWorkbenchLotLabelReminderText(null);
  698. setWorkbenchLotLabelContextLot(null);
  699. }, [qrScanSuccess, workbenchLotLabelModalOpen]);
  700. // Add these missing state variables after line 352
  701. const [isManualScanning, setIsManualScanning] = useState<boolean>(false);
  702. // Track processed stock-out lines per item (allow same physical lot QR for next SOL)
  703. const [processedQrCombinations, setProcessedQrCombinations] =
  704. useState<ProcessedStockOutLinesByItemId>(new Map());
  705. const [processedQrCodes, setProcessedQrCodes] = useState<Set<string>>(new Set());
  706. const [lastProcessedQr, setLastProcessedQr] = useState<string>('');
  707. const [isRefreshingData, setIsRefreshingData] = useState<boolean>(false);
  708. const [isSubmittingAll, setIsSubmittingAll] = useState<boolean>(false);
  709. // Cache for fetchStockInLineInfo API calls to avoid redundant requests
  710. const stockInLineInfoCache = useRef<Map<number, { lotNo: string | null; timestamp: number }>>(new Map());
  711. const CACHE_TTL = 60000; // 60 seconds cache TTL
  712. const abortControllerRef = useRef<AbortController | null>(null);
  713. const qrProcessingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
  714. // Use refs for processed QR tracking to avoid useEffect dependency issues and delays
  715. const processedQrCodesRef = useRef<Set<string>>(new Set());
  716. const lastProcessedQrRef = useRef<string>('');
  717. /** Prevent double-submit while a scan-pick API is in flight (scanner bounce). */
  718. const qrPickInFlightRef = useRef(false);
  719. /**
  720. * Only process newly appended qrValues entries.
  721. * Prevents refresh/deps re-running the effect from auto-picking the next SOL
  722. * with the same stale QR still sitting at the end of qrValues.
  723. */
  724. const lastConsumedQrValuesLengthRef = useRef(0);
  725. /** Latest lot indexes for QR re-scan allow checks outside processOutsideQrCode. */
  726. const lotDataIndexesRef = useRef<{
  727. byStockInLineId: Map<number, any[]>;
  728. activeLotsByItemId: Map<number, any[]>;
  729. } | null>(null);
  730. // Store callbacks in refs to avoid useEffect dependency issues
  731. const processOutsideQrCodeRef = useRef<
  732. ((latestQr: string, qrScanCountAtInvoke?: number) => Promise<void>) | null
  733. >(null);
  734. const resetScanRef = useRef<(() => void) | null>(null);
  735. // Handle QR code button click
  736. const handleQrCodeClick = (pickOrderId: number) => {
  737. console.log(`QR Code clicked for pick order ID: ${pickOrderId}`);
  738. // TODO: Implement QR code functionality
  739. };
  740. const progress = useMemo(() => {
  741. // 只给进度条统计可正常拣货的 lot(排除 unavailable / expired)
  742. const progressLots = combinedLotData.filter(
  743. (lot) => !isLotAvailabilityExpired(lot) && !isInventoryLotLineUnavailable(lot)
  744. );
  745. if (progressLots.length === 0) {
  746. return { completed: 0, total: 0 };
  747. }
  748. const completedCount = progressLots.filter((lot) => {
  749. const status = String(lot.stockOutLineStatus || "").toLowerCase();
  750. return status !== "pending";
  751. }).length;
  752. return {
  753. completed: completedCount,
  754. total: progressLots.length,
  755. };
  756. }, [combinedLotData]);
  757. // Cached version of fetchStockInLineInfo to avoid redundant API calls
  758. const fetchStockInLineInfoCached = useCallback(async (stockInLineId: number): Promise<{ lotNo: string | null }> => {
  759. const now = Date.now();
  760. const cached = stockInLineInfoCache.current.get(stockInLineId);
  761. // Return cached value if still valid
  762. if (cached && (now - cached.timestamp) < CACHE_TTL) {
  763. console.log(`✅ [CACHE HIT] Using cached stockInLineInfo for ${stockInLineId}`);
  764. return { lotNo: cached.lotNo };
  765. }
  766. // Cancel previous request if exists
  767. if (abortControllerRef.current) {
  768. abortControllerRef.current.abort();
  769. }
  770. // Create new abort controller for this request
  771. const abortController = new AbortController();
  772. abortControllerRef.current = abortController;
  773. try {
  774. console.log(` [CACHE MISS] Fetching stockInLineInfo for ${stockInLineId}`);
  775. const stockInLineInfo = await fetchStockInLineInfo(stockInLineId);
  776. // Store in cache
  777. stockInLineInfoCache.current.set(stockInLineId, {
  778. lotNo: stockInLineInfo.lotNo || null,
  779. timestamp: now
  780. });
  781. // Limit cache size to prevent memory leaks
  782. if (stockInLineInfoCache.current.size > 100) {
  783. const firstKey = stockInLineInfoCache.current.keys().next().value;
  784. if (firstKey !== undefined) {
  785. stockInLineInfoCache.current.delete(firstKey);
  786. }
  787. }
  788. return { lotNo: stockInLineInfo.lotNo || null };
  789. } catch (error: any) {
  790. if (error.name === 'AbortError') {
  791. console.log(` [CACHE] Request aborted for ${stockInLineId}`);
  792. throw error;
  793. }
  794. console.error(`❌ [CACHE] Error fetching stockInLineInfo for ${stockInLineId}:`, error);
  795. throw error;
  796. }
  797. }, []);
  798. const checkAllLotsCompleted = useCallback((lotData: any[]) => {
  799. if (lotData.length === 0) {
  800. setAllLotsCompleted(false);
  801. return false;
  802. }
  803. // Filter out rejected lots
  804. const nonRejectedLots = lotData.filter(lot =>
  805. lot.lotAvailability !== 'rejected' &&
  806. lot.stockOutLineStatus !== 'rejected'
  807. );
  808. if (nonRejectedLots.length === 0) {
  809. setAllLotsCompleted(false);
  810. return false;
  811. }
  812. // Check if all non-rejected lots are completed
  813. const allCompleted = nonRejectedLots.every(lot =>
  814. lot.stockOutLineStatus === 'completed'
  815. );
  816. setAllLotsCompleted(allCompleted);
  817. return allCompleted;
  818. }, []);
  819. // 在 fetchAllCombinedLotData 函数中(约 446-684 行)
  820. const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdOverride?: number) => {
  821. setCombinedDataLoading(true);
  822. try {
  823. const userIdToUse = userId || currentUserId;
  824. console.log(" fetchAllCombinedLotData called with userId:", userIdToUse);
  825. if (!userIdToUse) {
  826. console.warn("⚠️ No userId available, skipping API call");
  827. setCombinedLotData([]);
  828. setOriginalCombinedData([]);
  829. setAllLotsCompleted(false);
  830. setIssuePickedQtyBySolId({});
  831. return;
  832. }
  833. // 获取新结构的层级数据
  834. const hierarchicalData = await fetchAllPickOrderLotsHierarchicalWorkbench(userIdToUse);
  835. console.log(" Hierarchical data (new structure):", hierarchicalData);
  836. // 检查数据结构
  837. if (!hierarchicalData?.fgInfo || !hierarchicalData.pickOrders?.length) {
  838. console.warn("⚠️ No FG info or pick orders found");
  839. const hadWorkbenchData = workbenchHierarchicalReadyRef.current;
  840. const ticketForRedirect =
  841. String(hierarchicalData?.fgInfo?.ticketNo ?? "").trim() ||
  842. lastWorkbenchTicketNoRef.current ||
  843. "";
  844. const targetDateForRedirect =
  845. normalizeTargetDateInput(hierarchicalData?.pickOrders?.[0]?.targetDate) ||
  846. lastWorkbenchTargetDateRef.current ||
  847. "";
  848. setCombinedLotData([]);
  849. setOriginalCombinedData([]);
  850. setAllLotsCompleted(false);
  851. setIssuePickedQtyBySolId({});
  852. setFgPickOrders([]);
  853. workbenchHierarchicalReadyRef.current = false;
  854. if (hadWorkbenchData) {
  855. onWorkbenchHierarchyEmpty?.();
  856. }
  857. if (
  858. hadWorkbenchData &&
  859. ticketForRedirect &&
  860. !workbenchFinishNavigateDoneRef.current
  861. ) {
  862. workbenchFinishNavigateDoneRef.current = true;
  863. const redirectParams = new URLSearchParams();
  864. redirectParams.set("tab", "2");
  865. redirectParams.set("ticketNo", ticketForRedirect);
  866. if (targetDateForRedirect) {
  867. redirectParams.set("targetDate", targetDateForRedirect);
  868. }
  869. router.replace(`${pathname}?${redirectParams.toString()}`, { scroll: false });
  870. }
  871. return;
  872. }
  873. // 使用合并后的 pick order 对象(现在只有一个对象,包含所有数据)
  874. const mergedPickOrder = hierarchicalData.pickOrders[0];
  875. // 设置 FG info 到 fgPickOrders(用于显示 FG 信息卡片)
  876. // 修改第 478-509 行的 fgOrder 构建逻辑:
  877. const fgOrder: FGPickOrderResponse = {
  878. doPickOrderId: hierarchicalData.fgInfo.doPickOrderId,
  879. ticketNo: hierarchicalData.fgInfo.ticketNo,
  880. storeId: hierarchicalData.fgInfo.storeId,
  881. shopCode: hierarchicalData.fgInfo.shopCode,
  882. shopName: hierarchicalData.fgInfo.shopName,
  883. truckLanceCode: hierarchicalData.fgInfo.truckLanceCode,
  884. DepartureTime: hierarchicalData.fgInfo.departureTime,
  885. shopAddress: "",
  886. pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
  887. // 兼容字段(注意 consoCodes 是数组)
  888. pickOrderId: mergedPickOrder.pickOrderIds?.[0] || 0,
  889. pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes)
  890. ? mergedPickOrder.consoCodes[0] || ""
  891. : "",
  892. pickOrderTargetDate: mergedPickOrder.targetDate || "",
  893. pickOrderStatus: mergedPickOrder.status || "",
  894. deliveryOrderId: mergedPickOrder.doOrderIds?.[0] || 0,
  895. deliveryNo: mergedPickOrder.deliveryOrderCodes?.[0] || "",
  896. deliveryDate: "",
  897. shopId: 0,
  898. shopPoNo: "",
  899. numberOfCartons: mergedPickOrder.pickOrderLines?.length || 0,
  900. qrCodeData: hierarchicalData.fgInfo.doPickOrderId,
  901. // 多个 pick orders 信息:全部保留为数组
  902. numberOfPickOrders: mergedPickOrder.pickOrderIds?.length || 0,
  903. pickOrderIds: mergedPickOrder.pickOrderIds || [],
  904. pickOrderCodes: Array.isArray(mergedPickOrder.pickOrderCodes)
  905. ? mergedPickOrder.pickOrderCodes
  906. : [],
  907. deliveryOrderIds: mergedPickOrder.doOrderIds || [],
  908. deliveryNos: Array.isArray(mergedPickOrder.deliveryOrderCodes)
  909. ? mergedPickOrder.deliveryOrderCodes
  910. : [],
  911. lineCountsPerPickOrder: Array.isArray(mergedPickOrder.lineCountsPerPickOrder)
  912. ? mergedPickOrder.lineCountsPerPickOrder
  913. : [],
  914. };
  915. setFgPickOrders([fgOrder]);
  916. workbenchHierarchicalReadyRef.current = true;
  917. lastWorkbenchTicketNoRef.current =
  918. String(fgOrder.ticketNo ?? "").trim() || null;
  919. lastWorkbenchTargetDateRef.current =
  920. normalizeTargetDateInput(mergedPickOrder.targetDate);
  921. workbenchFinishNavigateDoneRef.current = false;
  922. console.log(" DEBUG fgOrder.lineCountsPerPickOrder:", fgOrder.lineCountsPerPickOrder);
  923. console.log(" DEBUG fgOrder.pickOrderCodes:", fgOrder.pickOrderCodes);
  924. console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos);
  925. // 直接使用合并后的 pickOrderLines
  926. console.log("🎯 Displaying merged pick order lines");
  927. // 将层级数据转换为平铺格式(用于表格显示)
  928. const flatLotData: any[] = [];
  929. // 2/F 與後端 store_id 一致時需按 itemOrder;避免 API 未走 2F 分支時畫面仍亂序
  930. const doFloorKey = String(hierarchicalData.fgInfo.storeId ?? '')
  931. .trim()
  932. .toUpperCase()
  933. .replace(/\//g, '')
  934. .replace(/\s/g, '');
  935. const pickOrderLinesForDisplay =
  936. doFloorKey === '2F'
  937. ? [...(mergedPickOrder.pickOrderLines || [])].sort((a: any, b: any) => {
  938. const ao = a.itemOrder != null ? Number(a.itemOrder) : 999999;
  939. const bo = b.itemOrder != null ? Number(b.itemOrder) : 999999;
  940. if (ao !== bo) return ao - bo;
  941. return (Number(a.id) || 0) - (Number(b.id) || 0);
  942. })
  943. : mergedPickOrder.pickOrderLines || [];
  944. pickOrderLinesForDisplay.forEach((line: any) => {
  945. // 用来记录这一行已经通过 lots 出现过的 lotId
  946. const lotIdSet = new Set<number>();
  947. // ✅ lots:按 lotId 去重并合并 requiredQty
  948. if (line.lots && line.lots.length > 0) {
  949. const lotMap = new Map<number, any>();
  950. line.lots.forEach((lot: any) => {
  951. const lotId = lot.id;
  952. if (lotMap.has(lotId)) {
  953. const existingLot = lotMap.get(lotId);
  954. existingLot.requiredQty =
  955. (existingLot.requiredQty || 0) + (lot.requiredQty || 0);
  956. } else {
  957. lotMap.set(lotId, { ...lot });
  958. }
  959. });
  960. lotMap.forEach((lot: any) => {
  961. if (lot.id != null) {
  962. lotIdSet.add(lot.id);
  963. }
  964. flatLotData.push({
  965. pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes)
  966. ? mergedPickOrder.consoCodes[0] || ""
  967. : "",
  968. pickOrderTargetDate: mergedPickOrder.targetDate,
  969. pickOrderStatus: mergedPickOrder.status,
  970. pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0,
  971. pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
  972. pickOrderLineId: line.id,
  973. pickOrderLineRequiredQty: line.requiredQty,
  974. pickOrderLineStatus: line.status,
  975. itemId: line.item.id,
  976. itemCode: line.item.code,
  977. itemName: line.item.name,
  978. uomId: line.item.uomId ?? null,
  979. uomDesc: line.item.uomDesc,
  980. uomShortDesc: line.item.uomShortDesc,
  981. lotId: lot.id,
  982. lotNo: lot.lotNo,
  983. expiryDate: lot.expiryDate,
  984. location: lot.location,
  985. stockUnit: lot.stockUnit,
  986. availableQty: lot.availableQty,
  987. requiredQty: lot.requiredQty,
  988. actualPickQty: lot.actualPickQty,
  989. inQty: lot.inQty,
  990. outQty: lot.outQty,
  991. holdQty: lot.holdQty,
  992. lotStatus: lot.lotStatus,
  993. lotAvailability: lot.lotAvailability,
  994. processingStatus: lot.processingStatus,
  995. suggestedPickLotId: lot.suggestedPickLotId,
  996. stockOutLineId: lot.stockOutLineId,
  997. stockOutLineStatus: lot.stockOutLineStatus,
  998. stockOutLineQty: lot.stockOutLineQty,
  999. stockOutLineRejectMessage: lot.stockOutLineRejectMessage ?? null,
  1000. stockInLineId: lot.stockInLineId,
  1001. routerId: lot.router?.id,
  1002. routerIndex: lot.router?.index,
  1003. routerRoute: lot.router?.route,
  1004. routerArea: lot.router?.area,
  1005. noLot: false,
  1006. });
  1007. });
  1008. }
  1009. // ✅ stockouts:只保留“真正无批次 / 未在 lots 出现过”的行
  1010. if (line.stockouts && line.stockouts.length > 0) {
  1011. line.stockouts.forEach((stockout: any) => {
  1012. const hasLot = stockout.lotId != null;
  1013. const lotAlreadyInLots =
  1014. hasLot && lotIdSet.has(stockout.lotId as number);
  1015. // 有批次 & 已经通过 lots 渲染过 → 跳过,避免一条变两行
  1016. if (!stockout.noLot && lotAlreadyInLots) {
  1017. return;
  1018. }
  1019. const stockoutRequiredQty = Number(
  1020. stockout?.requiredQty ?? stockout?.suggestedPickLotQty,
  1021. );
  1022. const effectiveStockoutRequiredQty = Number.isFinite(stockoutRequiredQty)
  1023. ? stockoutRequiredQty
  1024. : Number(line.requiredQty) || 0;
  1025. const fallbackRouteFromLine =
  1026. line?.lots?.[0]?.router?.route ?? line?.lots?.[0]?.location ?? null;
  1027. // 只渲染:
  1028. // - noLot === true 的 Null stock 行
  1029. // - 或者 lotId 在 lots 中不存在的特殊情况
  1030. flatLotData.push({
  1031. pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes)
  1032. ? mergedPickOrder.consoCodes[0] || ""
  1033. : "",
  1034. pickOrderTargetDate: mergedPickOrder.targetDate,
  1035. pickOrderStatus: mergedPickOrder.status,
  1036. pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0,
  1037. pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
  1038. pickOrderLineId: line.id,
  1039. pickOrderLineRequiredQty: line.requiredQty,
  1040. pickOrderLineStatus: line.status,
  1041. itemId: line.item.id,
  1042. itemCode: line.item.code,
  1043. itemName: line.item.name,
  1044. uomId: line.item.uomId ?? null,
  1045. uomDesc: line.item.uomDesc,
  1046. uomShortDesc: line.item.uomShortDesc,
  1047. lotId: stockout.lotId || null,
  1048. lotNo: stockout.lotNo || null,
  1049. expiryDate: null,
  1050. location: stockout.location || null,
  1051. stockUnit: line.item.uomDesc,
  1052. availableQty: stockout.availableQty || 0,
  1053. // Workbench stockout row required qty should come from backend stockout payload
  1054. // (linked by stockOutLineId to suggested_pick_lot), not inferred from line gap.
  1055. requiredQty: effectiveStockoutRequiredQty,
  1056. actualPickQty: stockout.qty || 0,
  1057. inQty: 0,
  1058. outQty: 0,
  1059. holdQty: 0,
  1060. lotStatus: stockout.noLot ? "unavailable" : "available",
  1061. lotAvailability: stockout.noLot ? "insufficient_stock" : "available",
  1062. processingStatus: stockout.status || "pending",
  1063. suggestedPickLotId: stockout.suggestedPickLotId ?? null,
  1064. stockOutLineId: stockout.id || null,
  1065. stockOutLineStatus: stockout.status || null,
  1066. stockOutLineQty: stockout.qty || 0,
  1067. stockOutLineRejectMessage: stockout.rejectMessage ?? stockout.rejectReason ?? null,
  1068. routerId: null,
  1069. routerIndex: stockout.noLot ? 999999 : null,
  1070. routerRoute: fallbackRouteFromLine,
  1071. routerArea: fallbackRouteFromLine,
  1072. noLot: !!stockout.noLot,
  1073. });
  1074. });
  1075. }
  1076. });
  1077. console.log(" Transformed flat lot data:", flatLotData);
  1078. console.log(" Total items (including null stock):", flatLotData.length);
  1079. setCombinedLotData(flatLotData);
  1080. setOriginalCombinedData(flatLotData);
  1081. const doPid = hierarchicalData.fgInfo?.doPickOrderId;
  1082. if (doPid) {
  1083. setIssuePickedQtyBySolId(loadIssuePickedMap(doPid));
  1084. }
  1085. checkAllLotsCompleted(flatLotData);
  1086. } catch (error) {
  1087. console.error(" Error fetching combined lot data:", error);
  1088. setCombinedLotData([]);
  1089. setOriginalCombinedData([]);
  1090. setAllLotsCompleted(false);
  1091. setIssuePickedQtyBySolId({});
  1092. } finally {
  1093. setCombinedDataLoading(false);
  1094. }
  1095. }, [
  1096. currentUserId,
  1097. checkAllLotsCompleted,
  1098. onWorkbenchHierarchyEmpty,
  1099. router,
  1100. pathname,
  1101. ]); // 移除 selectedPickOrderId 依赖
  1102. /** After workbench scan-pick (incl. split → new stock_out_line), reload hierarchical rows. */
  1103. const refreshWorkbenchAfterScanPick = useCallback(async () => {
  1104. setIsRefreshingData(true);
  1105. try {
  1106. await fetchAllCombinedLotData();
  1107. } finally {
  1108. setIsRefreshingData(false);
  1109. }
  1110. }, [fetchAllCombinedLotData]);
  1111. const openWorkbenchLotLabelModalForLot = useCallback(
  1112. (
  1113. lot: any,
  1114. reminderText?: string | null,
  1115. options?: { markAsScanIssue?: boolean },
  1116. ) => {
  1117. const itemId = Number(lot?.itemId);
  1118. const stockInLineId = Number(lot?.stockInLineId);
  1119. const solId = Number(lot?.stockOutLineId);
  1120. if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(solId) || solId <= 0) {
  1121. return;
  1122. }
  1123. // 掃碼 issue 才標記:手動「批號二維碼」不傳 markAsScanIssue,批號不變紅
  1124. if (options?.markAsScanIssue) {
  1125. rememberWorkbenchScanReject(solId, reminderText);
  1126. }
  1127. setWorkbenchLotLabelContextLot(lot);
  1128. if (Number.isFinite(stockInLineId) && stockInLineId > 0) {
  1129. setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId });
  1130. } else {
  1131. setWorkbenchLotLabelInitialPayload(null);
  1132. }
  1133. setWorkbenchLotLabelReminderText(reminderText ?? null);
  1134. // Clear latched success so the lot-label modal effect cannot instantly re-close on open.
  1135. setQrScanSuccess(false);
  1136. setWorkbenchLotLabelModalOpen(true);
  1137. },
  1138. [rememberWorkbenchScanReject],
  1139. );
  1140. const shouldOpenWorkbenchLotLabelModalForFailure = useCallback(
  1141. (code?: string | null, msg?: string | null): boolean => {
  1142. const normalizedCode = String(code || "").toUpperCase();
  1143. if (
  1144. normalizedCode.includes("UNAVAILABLE") ||
  1145. normalizedCode.includes("EXPIRED")
  1146. ) {
  1147. return true;
  1148. }
  1149. const normalizedMsg = String(msg || "").toLowerCase();
  1150. if (!normalizedMsg) return false;
  1151. return (
  1152. normalizedMsg.includes("unavailable") ||
  1153. normalizedMsg.includes("not available") ||
  1154. normalizedMsg.includes("expired") ||
  1155. normalizedMsg.includes("不可用") ||
  1156. normalizedMsg.includes("無法使用") ||
  1157. normalizedMsg.includes("过期")
  1158. );
  1159. },
  1160. [],
  1161. );
  1162. const handleWorkbenchLotLabelScanPick = useCallback(
  1163. async ({
  1164. inventoryLotLineId,
  1165. lotNo,
  1166. qty,
  1167. }: {
  1168. inventoryLotLineId: number;
  1169. lotNo: string;
  1170. qty?: number;
  1171. }) => {
  1172. const lot = workbenchLotLabelContextLot;
  1173. if (!lot?.stockOutLineId) {
  1174. throw new Error(t("Missing stock out line for this row."));
  1175. }
  1176. const qtyPayload = Number.isFinite(Number(qty))
  1177. ? { qty: Number(qty) }
  1178. : workbenchScanPickQtyFromLot(lot);
  1179. const res = await workbenchScanPick({
  1180. stockOutLineId: Number(lot.stockOutLineId),
  1181. lotNo: String(lotNo || "").trim(),
  1182. inventoryLotLineId,
  1183. storeId: fgPickOrders?.[0]?.storeId ?? null,
  1184. userId: currentUserId ?? 1,
  1185. ...qtyPayload,
  1186. });
  1187. if (res.code !== "SUCCESS") {
  1188. const errMsg =
  1189. (res as { message?: string })?.message ||
  1190. t("Workbench scan-pick failed.");
  1191. rememberWorkbenchScanReject(Number(lot.stockOutLineId), errMsg);
  1192. throw new Error(errMsg);
  1193. }
  1194. clearWorkbenchScanReject(Number(lot.stockOutLineId));
  1195. await refreshWorkbenchAfterScanPick();
  1196. setWorkbenchLotLabelModalOpen(false);
  1197. setWorkbenchLotLabelContextLot(null);
  1198. setWorkbenchLotLabelInitialPayload(null);
  1199. setWorkbenchLotLabelReminderText(null);
  1200. },
  1201. [
  1202. workbenchLotLabelContextLot,
  1203. fgPickOrders,
  1204. currentUserId,
  1205. workbenchScanPickQtyFromLot,
  1206. refreshWorkbenchAfterScanPick,
  1207. rememberWorkbenchScanReject,
  1208. clearWorkbenchScanReject,
  1209. t,
  1210. ],
  1211. );
  1212. const workbenchLotLabelStatusBanner = useMemo(() => {
  1213. if (!workbenchLotLabelModalOpen || !workbenchLotLabelContextLot) {
  1214. return {
  1215. text: undefined as string | undefined,
  1216. severity: undefined as "success" | "warning" | "error" | undefined,
  1217. };
  1218. }
  1219. const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot);
  1220. return { text: s.text, severity: s.severity };
  1221. }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]);
  1222. const workbenchLotLabelSubmitQty = useMemo(() => {
  1223. if (!workbenchLotLabelContextLot) return 0;
  1224. return Number(resolveSingleSubmitQty(workbenchLotLabelContextLot)) || 0;
  1225. }, [workbenchLotLabelContextLot, resolveSingleSubmitQty]);
  1226. const handleWorkbenchLotLabelSubmitQtyChange = useCallback(
  1227. (qty: number) => {
  1228. if (!workbenchLotLabelContextLot) return;
  1229. const lotKey = getWorkbenchQtyLotKey(workbenchLotLabelContextLot);
  1230. setPickQtyData((prev) => ({ ...prev, [lotKey]: qty }));
  1231. },
  1232. [workbenchLotLabelContextLot, getWorkbenchQtyLotKey],
  1233. );
  1234. /**
  1235. * 與「掃描結果」欄綠勾一致:有批號且 SOL 非 pending、非 rejected 時視為已掃/已提貨,停用掃碼提貨。
  1236. */
  1237. const workbenchLotLabelScanPickDisabled = useMemo(() => {
  1238. if (!workbenchLotLabelModalOpen || !workbenchLotLabelContextLot) return true;
  1239. const lot = workbenchLotLabelContextLot;
  1240. const status = String(lot.stockOutLineStatus || "").toLowerCase();
  1241. const isNoLot = !lot.lotNo || String(lot.lotNo).trim() === "";
  1242. if (isNoLot) return false;
  1243. if (status === "pending" || status === "rejected") return false;
  1244. return true;
  1245. }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]);
  1246. // Manual lot substitution is part of the legacy "checked then batch submit" flow.
  1247. // Workbench now uses scan-pick (immediate posting) + qty=0 zero-complete; disable this path to avoid writing `checked`.
  1248. const handleManualLotConfirmation = useCallback(async () => {
  1249. alert(t("Workbench does not support manual lot substitution in this flow. Please use scan-pick / Just Completed."));
  1250. }, [t]);
  1251. useEffect(() => {
  1252. if (combinedLotData.length > 0) {
  1253. checkAllLotsCompleted(combinedLotData);
  1254. }
  1255. }, [combinedLotData, checkAllLotsCompleted]);
  1256. // Add function to expose completion status to parent
  1257. const getCompletionStatus = useCallback(() => {
  1258. return allLotsCompleted;
  1259. }, [allLotsCompleted]);
  1260. // Expose completion status to parent component
  1261. useEffect(() => {
  1262. // Dispatch custom event with completion status
  1263. const event = new CustomEvent('pickOrderCompletionStatus', {
  1264. detail: {
  1265. allLotsCompleted,
  1266. tabIndex: 2 // DO workbench「Finished Good Record (mine)」分頁索引
  1267. }
  1268. });
  1269. window.dispatchEvent(event);
  1270. }, [allLotsCompleted]);
  1271. const handleQrCodeSubmit = useCallback(async (lotNo: string) => {
  1272. console.log(` Processing QR Code for lot: ${lotNo}`);
  1273. // 检查 lotNo 是否为 null 或 undefined(包括字符串 "null")
  1274. if (!lotNo || lotNo === 'null' || lotNo.trim() === '') {
  1275. console.error(" Invalid lotNo: null, undefined, or empty");
  1276. return;
  1277. }
  1278. // Use current data without refreshing to avoid infinite loop
  1279. const currentLotData = combinedLotData;
  1280. console.log(` Available lots:`, currentLotData.map(lot => lot.lotNo));
  1281. // 修复:在比较前确保 lotNo 不为 null
  1282. const lotNoLower = lotNo.toLowerCase();
  1283. const matchingLots = currentLotData.filter(lot => {
  1284. if (!lot.lotNo) return false; // 跳过 null lotNo
  1285. return lot.lotNo === lotNo || lot.lotNo.toLowerCase() === lotNoLower;
  1286. });
  1287. if (matchingLots.length === 0) {
  1288. console.error(` Lot not found: ${lotNo}`);
  1289. setQrScanError(true);
  1290. setQrScanSuccess(false);
  1291. const availableLotNos = currentLotData.map(lot => lot.lotNo).join(', ');
  1292. console.log(` QR Code "${lotNo}" does not match any expected lots. Available lots: ${availableLotNos}`);
  1293. return;
  1294. }
  1295. const hasExpiredLot = matchingLots.some(
  1296. (lot: any) => String(lot.lotAvailability || '').toLowerCase() === 'expired'
  1297. );
  1298. if (hasExpiredLot) {
  1299. console.warn(`⚠️ [QR PROCESS] Scanned lot ${lotNo} is expired`);
  1300. setQrScanError(true);
  1301. setQrScanSuccess(false);
  1302. return;
  1303. }
  1304. // Legacy QR flow marked SOL as `checked` (normal version). Workbench uses scan-pick (immediate posting).
  1305. setQrScanError(true);
  1306. setQrScanSuccess(false);
  1307. setQrScanErrorMsg(
  1308. t("Workbench uses scan-pick. Please use Just Completed / submit via scan-pick instead of checked status."),
  1309. );
  1310. }, [combinedLotData]);
  1311. const handleFastQrScan = useCallback(async (lotNo: string) => {
  1312. const startTime = performance.now();
  1313. console.log(` [FAST SCAN START] Lot: ${lotNo}`);
  1314. console.log(` Start time: ${new Date().toISOString()}`);
  1315. // 从 combinedLotData 中找到对应的 lot
  1316. const findStartTime = performance.now();
  1317. const matchingLot = combinedLotData.find(lot =>
  1318. lot.lotNo && lot.lotNo === lotNo
  1319. );
  1320. const findTime = performance.now() - findStartTime;
  1321. console.log(` Find lot time: ${findTime.toFixed(2)}ms`);
  1322. if (!matchingLot || !matchingLot.stockOutLineId) {
  1323. const totalTime = performance.now() - startTime;
  1324. console.warn(`⚠️ Fast scan: Lot ${lotNo} not found or no stockOutLineId`);
  1325. console.log(` Total time: ${totalTime.toFixed(2)}ms`);
  1326. return;
  1327. }
  1328. try {
  1329. const apiStartTime = performance.now();
  1330. const res = await workbenchScanPick({
  1331. stockOutLineId: matchingLot.stockOutLineId,
  1332. lotNo,
  1333. ...(typeof matchingLot.stockInLineId === "number" &&
  1334. Number.isFinite(matchingLot.stockInLineId) &&
  1335. matchingLot.stockInLineId > 0
  1336. ? { stockInLineId: matchingLot.stockInLineId }
  1337. : {}),
  1338. ...workbenchScanPickQtyFromLot(matchingLot),
  1339. storeId: fgPickOrders?.[0]?.storeId ?? null,
  1340. userId: currentUserId ?? 1,
  1341. });
  1342. const apiTime = performance.now() - apiStartTime;
  1343. console.log(` API call time: ${apiTime.toFixed(2)}ms`);
  1344. const ok = res.code === "SUCCESS";
  1345. if (ok) {
  1346. clearWorkbenchScanReject(Number(matchingLot.stockOutLineId));
  1347. const entity = res.entity as any;
  1348. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  1349. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  1350. const patchRow = (prev: any[]) =>
  1351. prev.map((row) => {
  1352. if (
  1353. row.stockOutLineId === matchingLot.stockOutLineId &&
  1354. row.pickOrderLineId === matchingLot.pickOrderLineId
  1355. ) {
  1356. return {
  1357. ...row,
  1358. stockOutLineStatus: nextStatus,
  1359. stockOutLineQty: nextQty ?? row.stockOutLineQty,
  1360. actualPickQty: nextQty ?? row.actualPickQty,
  1361. };
  1362. }
  1363. return row;
  1364. });
  1365. setCombinedLotData(patchRow);
  1366. setOriginalCombinedData(patchRow);
  1367. const totalTime = performance.now() - startTime;
  1368. console.log(`✅ [FAST SCAN END] Lot: ${lotNo}`);
  1369. console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`);
  1370. console.log(` End time: ${new Date().toISOString()}`);
  1371. } else {
  1372. const totalTime = performance.now() - startTime;
  1373. console.warn(`⚠️ Fast scan failed for ${lotNo}:`, res.code);
  1374. console.log(` Total time: ${totalTime.toFixed(2)}ms`);
  1375. if (workbenchMode && matchingLot.stockOutLineId != null) {
  1376. rememberWorkbenchScanReject(
  1377. Number(matchingLot.stockOutLineId),
  1378. (res as { message?: string })?.message,
  1379. );
  1380. }
  1381. }
  1382. } catch (error) {
  1383. const totalTime = performance.now() - startTime;
  1384. console.error(` Fast scan error for ${lotNo}:`, error);
  1385. console.log(` Total time: ${totalTime.toFixed(2)}ms`);
  1386. }
  1387. }, [combinedLotData, updateStockOutLineStatusByQRCodeAndLotNo, workbenchMode, currentUserId, clearWorkbenchScanReject, rememberWorkbenchScanReject, refreshWorkbenchAfterScanPick, workbenchScanPickQtyFromLot]);
  1388. // Enhanced lotDataIndexes with cached active lots for better performance
  1389. const lotDataIndexes = useMemo(() => {
  1390. const indexStartTime = performance.now();
  1391. console.log(` [PERF] lotDataIndexes calculation START, data length: ${combinedLotData.length}`);
  1392. const byItemId = new Map<number, any[]>();
  1393. const byItemCode = new Map<string, any[]>();
  1394. const byLotId = new Map<number, any>();
  1395. const byLotNo = new Map<string, any[]>();
  1396. const byStockInLineId = new Map<number, any[]>();
  1397. // Cache active lots separately to avoid filtering on every scan
  1398. const activeLotsByItemId = new Map<number, any[]>();
  1399. const rejectedStatuses = new Set(['rejected']);
  1400. // ✅ Use for loop instead of forEach for better performance on tablets
  1401. for (let i = 0; i < combinedLotData.length; i++) {
  1402. const lot = combinedLotData[i];
  1403. const solStatus = String(lot.stockOutLineStatus || "").toLowerCase();
  1404. const lotAvailability = String(lot.lotAvailability || "").toLowerCase();
  1405. const processingStatus = String(lot.processingStatus || "").toLowerCase();
  1406. const isUnavailable = isInventoryLotLineUnavailable(lot);
  1407. const isExpired = isLotAvailabilityExpired(lot);
  1408. const isRejected =
  1409. rejectedStatuses.has(lotAvailability) ||
  1410. rejectedStatuses.has(solStatus) ||
  1411. rejectedStatuses.has(processingStatus);
  1412. const isEnded = solStatus === "checked" || solStatus === "completed";
  1413. const isPartially = solStatus === "partially_completed" || solStatus === "partially_complete";
  1414. const isPending = solStatus === "pending" || solStatus === "";
  1415. const isActive = !isRejected && !isUnavailable && !isExpired && !isEnded && (isPending || isPartially);
  1416. if (lot.itemId) {
  1417. if (!byItemId.has(lot.itemId)) {
  1418. byItemId.set(lot.itemId, []);
  1419. activeLotsByItemId.set(lot.itemId, []);
  1420. }
  1421. byItemId.get(lot.itemId)!.push(lot);
  1422. if (isActive) {
  1423. activeLotsByItemId.get(lot.itemId)!.push(lot);
  1424. }
  1425. }
  1426. if (lot.itemCode) {
  1427. if (!byItemCode.has(lot.itemCode)) {
  1428. byItemCode.set(lot.itemCode, []);
  1429. }
  1430. byItemCode.get(lot.itemCode)!.push(lot);
  1431. }
  1432. if (lot.lotId) {
  1433. byLotId.set(lot.lotId, lot);
  1434. }
  1435. if (lot.lotNo) {
  1436. if (!byLotNo.has(lot.lotNo)) {
  1437. byLotNo.set(lot.lotNo, []);
  1438. }
  1439. byLotNo.get(lot.lotNo)!.push(lot);
  1440. }
  1441. if (lot.stockInLineId) {
  1442. if (!byStockInLineId.has(lot.stockInLineId)) {
  1443. byStockInLineId.set(lot.stockInLineId, []);
  1444. }
  1445. byStockInLineId.get(lot.stockInLineId)!.push(lot);
  1446. }
  1447. }
  1448. const indexTime = performance.now() - indexStartTime;
  1449. if (indexTime > 10) {
  1450. console.log(` [PERF] lotDataIndexes calculation END: ${indexTime.toFixed(2)}ms (${(indexTime / 1000).toFixed(3)}s)`);
  1451. }
  1452. return { byItemId, byItemCode, byLotId, byLotNo, byStockInLineId, activeLotsByItemId };
  1453. }, [combinedLotData.length, combinedLotData]);
  1454. lotDataIndexesRef.current = {
  1455. byStockInLineId: lotDataIndexes.byStockInLineId,
  1456. activeLotsByItemId: lotDataIndexes.activeLotsByItemId,
  1457. };
  1458. // Store resetScan in ref for immediate access (update on every render)
  1459. resetScanRef.current = resetScan;
  1460. const processOutsideQrCode = useCallback(async (latestQr: string, qrScanCountAtInvoke?: number) => {
  1461. if (qrPickInFlightRef.current) {
  1462. console.log(" [SKIP] QR pick already in flight");
  1463. return;
  1464. }
  1465. qrPickInFlightRef.current = true;
  1466. const totalStartTime = performance.now();
  1467. console.log(` [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`);
  1468. console.log(` Start time: ${new Date().toISOString()}`);
  1469. // ✅ Measure index access time
  1470. const indexAccessStart = performance.now();
  1471. const indexes = lotDataIndexes; // Access the memoized indexes
  1472. const indexAccessTime = performance.now() - indexAccessStart;
  1473. console.log(` [PERF] Index access time: ${indexAccessTime.toFixed(2)}ms`);
  1474. const maybeReleaseQrForNextSol = (
  1475. itemId: number,
  1476. stockInLineId: number,
  1477. processedAfterMark: ProcessedStockOutLinesByItemId,
  1478. ) => {
  1479. if (
  1480. hasPendingActiveRowForStockInLine(
  1481. indexes,
  1482. itemId,
  1483. stockInLineId,
  1484. processedAfterMark,
  1485. )
  1486. ) {
  1487. lastProcessedQrRef.current = "";
  1488. processedQrCodesRef.current.delete(latestQr);
  1489. }
  1490. };
  1491. try {
  1492. // 1) Parse JSON safely (parse once, reuse)
  1493. const parseStartTime = performance.now();
  1494. let qrData: any = null;
  1495. let parseTime = 0;
  1496. try {
  1497. qrData = JSON.parse(latestQr);
  1498. parseTime = performance.now() - parseStartTime;
  1499. console.log(` [PERF] JSON parse time: ${parseTime.toFixed(2)}ms`);
  1500. } catch {
  1501. console.log("QR content is not JSON; skipping lotNo direct submit to avoid false matches.");
  1502. startTransition(() => {
  1503. setQrScanError(true);
  1504. setQrScanSuccess(false);
  1505. });
  1506. return;
  1507. }
  1508. try {
  1509. const validationStartTime = performance.now();
  1510. if (!(qrData?.stockInLineId && qrData?.itemId)) {
  1511. console.log("QR JSON missing required fields (itemId, stockInLineId).");
  1512. startTransition(() => {
  1513. setQrScanError(true);
  1514. setQrScanSuccess(false);
  1515. });
  1516. return;
  1517. }
  1518. const validationTime = performance.now() - validationStartTime;
  1519. console.log(` [PERF] Validation time: ${validationTime.toFixed(2)}ms`);
  1520. const scannedItemId = qrData.itemId;
  1521. const scannedStockInLineId = qrData.stockInLineId;
  1522. if (
  1523. !canProcessWorkbenchScanPick(
  1524. indexes,
  1525. scannedItemId,
  1526. scannedStockInLineId,
  1527. processedQrCombinations,
  1528. )
  1529. ) {
  1530. const itemRows = indexes.byItemId.get(scannedItemId) ?? [];
  1531. const errMsg =
  1532. itemRows.length === 0
  1533. ? t("QR code does not match any item in current orders.")
  1534. : t(
  1535. "No pending pick line left for this item. It may already be completed or fully processed.",
  1536. );
  1537. console.log(
  1538. ` [SKIP] No pending stock-out line left for itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId}`,
  1539. );
  1540. startTransition(() => {
  1541. setQrScanError(true);
  1542. setQrScanSuccess(false);
  1543. setQrScanErrorMsg(errMsg);
  1544. });
  1545. return;
  1546. }
  1547. // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed)
  1548. const lookupStartTime = performance.now();
  1549. let activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || [];
  1550. // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected
  1551. let allLotsForItem = indexes.byItemId.get(scannedItemId) || [];
  1552. const lookupTime = performance.now() - lookupStartTime;
  1553. console.log(` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`);
  1554. // Case A: reject only if scanned lot UOM matches none of this item's POL UOMs
  1555. // (same item may have multiple lines with different UOMs on one pick order)
  1556. let scannedUomId = 0;
  1557. const allowedUomIds = new Set(
  1558. allLotsForItem
  1559. .map((l: any) => Number(l?.uomId))
  1560. .filter((id: number) => Number.isFinite(id) && id > 0),
  1561. );
  1562. const hasLineUoms = allowedUomIds.size > 0;
  1563. try {
  1564. const lotDetail = await fetchLotDetail(scannedStockInLineId);
  1565. scannedUomId = Number(lotDetail?.uomId) || 0;
  1566. } catch (e) {
  1567. if (hasLineUoms) {
  1568. console.warn(
  1569. "[QR PROCESS] lot-detail UOM check failed; rejecting scan",
  1570. e,
  1571. );
  1572. startTransition(() => {
  1573. setQrScanError(true);
  1574. setQrScanSuccess(false);
  1575. setQrScanErrorMsg(
  1576. t(
  1577. "This lot UOM does not match the pick line. Please scan another lot.",
  1578. ),
  1579. );
  1580. });
  1581. lastProcessedQrRef.current = "";
  1582. processedQrCodesRef.current.delete(latestQr);
  1583. return;
  1584. }
  1585. }
  1586. if (
  1587. hasLineUoms &&
  1588. (!(scannedUomId > 0) || !allowedUomIds.has(scannedUomId))
  1589. ) {
  1590. console.warn(
  1591. ` [QR PROCESS] UOM mismatch: allowed=[${Array.from(allowedUomIds).join(",")}], scannedLot=${scannedUomId}, stockInLineId=${scannedStockInLineId}`,
  1592. );
  1593. startTransition(() => {
  1594. setQrScanError(true);
  1595. setQrScanSuccess(false);
  1596. setQrScanErrorMsg(
  1597. t(
  1598. "This lot UOM does not match the pick line. Please scan another lot.",
  1599. ),
  1600. );
  1601. });
  1602. lastProcessedQrRef.current = "";
  1603. processedQrCodesRef.current.delete(latestQr);
  1604. return;
  1605. }
  1606. // Strict UOM filter — never fall back to other UOM lines
  1607. if (scannedUomId > 0 && hasLineUoms) {
  1608. allLotsForItem = allLotsForItem.filter(
  1609. (l: any) => Number(l?.uomId) === scannedUomId,
  1610. );
  1611. activeSuggestedLots = activeSuggestedLots.filter(
  1612. (l: any) => Number(l?.uomId) === scannedUomId,
  1613. );
  1614. if (allLotsForItem.length === 0) {
  1615. startTransition(() => {
  1616. setQrScanError(true);
  1617. setQrScanSuccess(false);
  1618. setQrScanErrorMsg(
  1619. t(
  1620. "This lot UOM does not match the pick line. Please scan another lot.",
  1621. ),
  1622. );
  1623. });
  1624. lastProcessedQrRef.current = "";
  1625. processedQrCodesRef.current.delete(latestQr);
  1626. return;
  1627. }
  1628. }
  1629. // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots
  1630. // This allows users to scan other lots even when all suggested lots are rejected
  1631. const stockInLineRows = indexes.byStockInLineId.get(scannedStockInLineId) ?? [];
  1632. const scannedLot =
  1633. findExactActiveMatchForStockInLine(
  1634. stockInLineRows,
  1635. scannedItemId,
  1636. activeSuggestedLots,
  1637. processedQrCombinations,
  1638. ) ??
  1639. allLotsForItem.find(
  1640. (lot: any) => lot.stockInLineId === scannedStockInLineId,
  1641. );
  1642. if (scannedLot) {
  1643. const isRejected =
  1644. scannedLot.stockOutLineStatus?.toLowerCase() === 'rejected' ||
  1645. scannedLot.lotAvailability === 'rejected';
  1646. const isUnavailable = isInventoryLotLineUnavailable(scannedLot);
  1647. if (isRejected) {
  1648. console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected`);
  1649. startTransition(() => {
  1650. setQrScanError(true);
  1651. setQrScanSuccess(false);
  1652. setQrScanErrorMsg(
  1653. `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`
  1654. );
  1655. });
  1656. // Mark this SOL as processed to prevent re-processing
  1657. setProcessedQrCombinations((prev) =>
  1658. markProcessedStockOutLine(
  1659. prev,
  1660. scannedItemId,
  1661. scannedLot.stockOutLineId,
  1662. ),
  1663. );
  1664. return;
  1665. }
  1666. if (isUnavailable) {
  1667. console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is unavailable; opening lot-label modal`);
  1668. startTransition(() => {
  1669. setQrScanError(false);
  1670. setQrScanSuccess(false);
  1671. });
  1672. openWorkbenchLotLabelModalForLot(
  1673. scannedLot,
  1674. t("This lot is not available, please scan another lot."),
  1675. { markAsScanIssue: true },
  1676. );
  1677. return;
  1678. }
  1679. const isExpired =
  1680. String(scannedLot.lotAvailability || '').toLowerCase() === 'expired';
  1681. if (isExpired) {
  1682. console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired; opening lot-label modal`);
  1683. startTransition(() => {
  1684. setQrScanError(false);
  1685. setQrScanSuccess(false);
  1686. });
  1687. openWorkbenchLotLabelModalForLot(
  1688. scannedLot,
  1689. `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`,
  1690. { markAsScanIssue: true },
  1691. );
  1692. return;
  1693. }
  1694. }
  1695. // ✅ If no active suggested lots, auto-switch to scanned lot (no modal)
  1696. if (activeSuggestedLots.length === 0) {
  1697. // Check if there are any lots for this item (even if all are rejected)
  1698. if (allLotsForItem.length === 0) {
  1699. console.error("No lots found for this item");
  1700. startTransition(() => {
  1701. setQrScanError(true);
  1702. setQrScanSuccess(false);
  1703. setQrScanErrorMsg("当前订单中没有此物品的批次信息");
  1704. });
  1705. return;
  1706. }
  1707. console.log(`⚠️ [QR PROCESS] No active suggested lots, auto-switching to scanned lot.`);
  1708. // Prefer pending noLot / rejected / unavailable rows (not completed ones).
  1709. const switchableLot = allLotsForItem.find(
  1710. (lot: any) =>
  1711. isLotRowPending(lot) &&
  1712. !isStockOutLineAlreadyProcessed(
  1713. processedQrCombinations,
  1714. scannedItemId,
  1715. lot.stockOutLineId,
  1716. ) &&
  1717. (lot.noLot === true ||
  1718. lot.stockOutLineStatus?.toLowerCase() === "rejected" ||
  1719. lot.lotAvailability === "rejected" ||
  1720. isInventoryLotLineUnavailable(lot) ||
  1721. isLotAvailabilityExpired(lot)),
  1722. );
  1723. const expectedLot =
  1724. switchableLot ||
  1725. pickExpectedLotForSubstitution(
  1726. allLotsForItem.filter(
  1727. (l: any) => l.lotNo != null && String(l.lotNo).trim() !== ""
  1728. ),
  1729. processedQrCombinations,
  1730. ) ||
  1731. allLotsForItem.find(
  1732. (lot: any) =>
  1733. isLotRowPending(lot) &&
  1734. !isStockOutLineAlreadyProcessed(
  1735. processedQrCombinations,
  1736. scannedItemId,
  1737. lot.stockOutLineId,
  1738. ),
  1739. ) ||
  1740. allLotsForItem[0];
  1741. let scannedLotNo: string | null = scannedLot?.lotNo || null;
  1742. if (!scannedLotNo) {
  1743. try {
  1744. const info = await fetchStockInLineInfoCached(scannedStockInLineId);
  1745. scannedLotNo = info?.lotNo || null;
  1746. } catch (e) {
  1747. console.warn("Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e);
  1748. }
  1749. }
  1750. if (!scannedLotNo) {
  1751. startTransition(() => {
  1752. setQrScanError(true);
  1753. setQrScanSuccess(false);
  1754. setQrScanErrorMsg(
  1755. t("Cannot resolve lot number from QR. Please rescan or use manual confirmation.")
  1756. );
  1757. });
  1758. return;
  1759. }
  1760. if (!workbenchMode) {
  1761. const substitutionResult = await confirmLotSubstitution({
  1762. pickOrderLineId: expectedLot.pickOrderLineId,
  1763. stockOutLineId: expectedLot.stockOutLineId,
  1764. originalSuggestedPickLotId: expectedLot.suggestedPickLotId,
  1765. newInventoryLotNo: "",
  1766. newStockInLineId: scannedStockInLineId,
  1767. });
  1768. const substitutionCode = (substitutionResult as any)?.code;
  1769. const switchedToUnavailable =
  1770. substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE";
  1771. if (!substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable)) {
  1772. const errMsg =
  1773. substitutionResult?.message ||
  1774. t("Lot switch failed; pick line was not updated.");
  1775. startTransition(() => {
  1776. setQrScanError(true);
  1777. setQrScanSuccess(false);
  1778. setQrScanErrorMsg(errMsg);
  1779. });
  1780. if (expectedLot.stockOutLineId != null) {
  1781. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), errMsg);
  1782. }
  1783. return;
  1784. }
  1785. }
  1786. const res = await workbenchScanPick({
  1787. stockOutLineId: expectedLot.stockOutLineId,
  1788. lotNo: scannedLotNo,
  1789. ...(typeof scannedStockInLineId === "number" &&
  1790. Number.isFinite(scannedStockInLineId) &&
  1791. scannedStockInLineId > 0
  1792. ? { stockInLineId: scannedStockInLineId }
  1793. : {}),
  1794. ...workbenchScanPickQtyFromLot(expectedLot),
  1795. storeId: fgPickOrders?.[0]?.storeId ?? null,
  1796. userId: currentUserId ?? 1,
  1797. });
  1798. const ok = res.code === "SUCCESS";
  1799. if (!ok) {
  1800. const failMsg =
  1801. (res as { message?: string })?.message ||
  1802. t("Workbench scan-pick failed.");
  1803. if (
  1804. shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
  1805. expectedLot
  1806. ) {
  1807. openWorkbenchLotLabelModalForLot(expectedLot, failMsg, {
  1808. markAsScanIssue: true,
  1809. });
  1810. return;
  1811. }
  1812. if (workbenchMode && expectedLot.stockOutLineId != null) {
  1813. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), failMsg);
  1814. }
  1815. startTransition(() => {
  1816. setQrScanError(true);
  1817. setQrScanSuccess(false);
  1818. setQrScanErrorMsg(failMsg);
  1819. });
  1820. return;
  1821. }
  1822. clearWorkbenchScanReject(Number(expectedLot.stockOutLineId));
  1823. const entity = res.entity as any;
  1824. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  1825. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  1826. startTransition(() => {
  1827. setQrScanError(false);
  1828. setQrScanSuccess(true);
  1829. setCombinedLotData((prev) =>
  1830. prev.map((lot) => {
  1831. if (
  1832. lot.stockOutLineId === expectedLot.stockOutLineId &&
  1833. lot.pickOrderLineId === expectedLot.pickOrderLineId
  1834. ) {
  1835. return {
  1836. ...lot,
  1837. lotNo: scannedLotNo,
  1838. stockOutLineStatus: nextStatus,
  1839. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  1840. };
  1841. }
  1842. return lot;
  1843. }),
  1844. );
  1845. setOriginalCombinedData((prev) =>
  1846. prev.map((lot) => {
  1847. if (
  1848. lot.stockOutLineId === expectedLot.stockOutLineId &&
  1849. lot.pickOrderLineId === expectedLot.pickOrderLineId
  1850. ) {
  1851. return {
  1852. ...lot,
  1853. lotNo: scannedLotNo,
  1854. stockOutLineStatus: nextStatus,
  1855. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  1856. };
  1857. }
  1858. return lot;
  1859. }),
  1860. );
  1861. });
  1862. const nextProcessedNoActive = markProcessedStockOutLine(
  1863. processedQrCombinations,
  1864. scannedItemId,
  1865. expectedLot.stockOutLineId,
  1866. );
  1867. setProcessedQrCombinations(nextProcessedNoActive);
  1868. maybeReleaseQrForNextSol(
  1869. scannedItemId,
  1870. scannedStockInLineId,
  1871. nextProcessedNoActive,
  1872. );
  1873. if (workbenchMode) {
  1874. await refreshWorkbenchAfterScanPick();
  1875. }
  1876. return;
  1877. }
  1878. // ✅ OPTIMIZATION: Direct Map lookup for stockInLineId match (O(1))
  1879. const matchStartTime = performance.now();
  1880. const stockInLineLots = indexes.byStockInLineId.get(scannedStockInLineId) || [];
  1881. const exactMatch = findExactActiveMatchForStockInLine(
  1882. stockInLineLots,
  1883. scannedItemId,
  1884. activeSuggestedLots,
  1885. processedQrCombinations,
  1886. );
  1887. const matchTime = performance.now() - matchStartTime;
  1888. console.log(` [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${exactMatch ? 'yes' : 'no'}`);
  1889. // ✅ Check if scanned lot exists in allLotsForItem but not in activeSuggestedLots
  1890. // This handles the case where Lot A is rejected and user scans Lot B
  1891. // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined)
  1892. if (!exactMatch) {
  1893. const expectedLot =
  1894. pickExpectedLotForSubstitution(
  1895. activeSuggestedLots,
  1896. processedQrCombinations,
  1897. ) || allLotsForItem[0];
  1898. if (expectedLot) {
  1899. const shouldAutoSwitch =
  1900. !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId);
  1901. if (shouldAutoSwitch) {
  1902. console.log(
  1903. `⚠️ [QR PROCESS] Auto-switching (scanned lot ${scannedLot?.lotNo || 'not in data'} is not in active suggested lots)`,
  1904. );
  1905. let scannedLotNo: string | null = scannedLot?.lotNo || null;
  1906. if (!scannedLotNo) {
  1907. try {
  1908. const info = await fetchStockInLineInfoCached(scannedStockInLineId);
  1909. scannedLotNo = info?.lotNo || null;
  1910. } catch (e) {
  1911. console.warn("Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e);
  1912. }
  1913. }
  1914. if (!scannedLotNo) {
  1915. startTransition(() => {
  1916. setQrScanError(true);
  1917. setQrScanSuccess(false);
  1918. setQrScanErrorMsg(
  1919. t("Cannot resolve lot number from QR. Please rescan or use manual confirmation.")
  1920. );
  1921. });
  1922. return;
  1923. }
  1924. if (!workbenchMode) {
  1925. const substitutionResult = await confirmLotSubstitution({
  1926. pickOrderLineId: expectedLot.pickOrderLineId,
  1927. stockOutLineId: expectedLot.stockOutLineId,
  1928. originalSuggestedPickLotId: expectedLot.suggestedPickLotId,
  1929. newInventoryLotNo: "",
  1930. newStockInLineId: scannedStockInLineId,
  1931. });
  1932. const substitutionCode = (substitutionResult as any)?.code;
  1933. const switchedToUnavailable =
  1934. substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE";
  1935. if (!substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable)) {
  1936. const errMsg =
  1937. substitutionResult?.message ||
  1938. t("Lot switch failed; pick line was not updated.");
  1939. startTransition(() => {
  1940. setQrScanError(true);
  1941. setQrScanSuccess(false);
  1942. setQrScanErrorMsg(errMsg);
  1943. });
  1944. if (expectedLot.stockOutLineId != null) {
  1945. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), errMsg);
  1946. }
  1947. return;
  1948. }
  1949. }
  1950. const res = await workbenchScanPick({
  1951. stockOutLineId: expectedLot.stockOutLineId,
  1952. lotNo: scannedLotNo,
  1953. ...(typeof scannedStockInLineId === "number" &&
  1954. Number.isFinite(scannedStockInLineId) &&
  1955. scannedStockInLineId > 0
  1956. ? { stockInLineId: scannedStockInLineId }
  1957. : {}),
  1958. ...workbenchScanPickQtyFromLot(expectedLot),
  1959. storeId: fgPickOrders?.[0]?.storeId ?? null,
  1960. userId: currentUserId ?? 1,
  1961. });
  1962. const ok = res.code === "SUCCESS";
  1963. if (!ok) {
  1964. const failMsg =
  1965. (res as { message?: string })?.message ||
  1966. t("Workbench scan-pick failed.");
  1967. if (
  1968. shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
  1969. expectedLot
  1970. ) {
  1971. openWorkbenchLotLabelModalForLot(expectedLot, failMsg, {
  1972. markAsScanIssue: true,
  1973. });
  1974. return;
  1975. }
  1976. if (workbenchMode && expectedLot.stockOutLineId != null) {
  1977. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), failMsg);
  1978. }
  1979. startTransition(() => {
  1980. setQrScanError(true);
  1981. setQrScanSuccess(false);
  1982. setQrScanErrorMsg(failMsg);
  1983. });
  1984. return;
  1985. }
  1986. clearWorkbenchScanReject(Number(expectedLot.stockOutLineId));
  1987. const entity = res.entity as any;
  1988. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  1989. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  1990. startTransition(() => {
  1991. setQrScanError(false);
  1992. setQrScanSuccess(true);
  1993. setCombinedLotData((prev) =>
  1994. prev.map((lot) => {
  1995. if (
  1996. lot.stockOutLineId === expectedLot.stockOutLineId &&
  1997. lot.pickOrderLineId === expectedLot.pickOrderLineId
  1998. ) {
  1999. return {
  2000. ...lot,
  2001. lotNo: scannedLotNo,
  2002. stockOutLineStatus: nextStatus,
  2003. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  2004. };
  2005. }
  2006. return lot;
  2007. }),
  2008. );
  2009. setOriginalCombinedData((prev) =>
  2010. prev.map((lot) => {
  2011. if (
  2012. lot.stockOutLineId === expectedLot.stockOutLineId &&
  2013. lot.pickOrderLineId === expectedLot.pickOrderLineId
  2014. ) {
  2015. return {
  2016. ...lot,
  2017. lotNo: scannedLotNo,
  2018. stockOutLineStatus: nextStatus,
  2019. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  2020. };
  2021. }
  2022. return lot;
  2023. }),
  2024. );
  2025. });
  2026. const nextProcessedAuto = markProcessedStockOutLine(
  2027. processedQrCombinations,
  2028. scannedItemId,
  2029. expectedLot.stockOutLineId,
  2030. );
  2031. setProcessedQrCombinations(nextProcessedAuto);
  2032. maybeReleaseQrForNextSol(
  2033. scannedItemId,
  2034. scannedStockInLineId,
  2035. nextProcessedAuto,
  2036. );
  2037. if (workbenchMode) {
  2038. await refreshWorkbenchAfterScanPick();
  2039. }
  2040. return;
  2041. }
  2042. }
  2043. }
  2044. if (exactMatch) {
  2045. // ✅ Case 1: stockInLineId 匹配 - 直接处理,不需要确认
  2046. console.log(`✅ Exact stockInLineId match found for lot: ${exactMatch.lotNo}`);
  2047. if (!exactMatch.stockOutLineId) {
  2048. console.warn("No stockOutLineId on exactMatch, cannot update status by QR.");
  2049. startTransition(() => {
  2050. setQrScanError(true);
  2051. setQrScanSuccess(false);
  2052. });
  2053. return;
  2054. }
  2055. try {
  2056. const apiStartTime = performance.now();
  2057. console.log(
  2058. workbenchMode
  2059. ? ` [API CALL START] workbenchScanPick`
  2060. : ` [API CALL START] Calling updateStockOutLineStatusByQRCodeAndLotNo`
  2061. );
  2062. console.log(` [API CALL] API start time: ${new Date().toISOString()}`);
  2063. const res = await workbenchScanPick({
  2064. stockOutLineId: exactMatch.stockOutLineId,
  2065. lotNo: exactMatch.lotNo,
  2066. ...(typeof scannedStockInLineId === "number" &&
  2067. Number.isFinite(scannedStockInLineId) &&
  2068. scannedStockInLineId > 0
  2069. ? { stockInLineId: scannedStockInLineId }
  2070. : typeof exactMatch.stockInLineId === "number" &&
  2071. Number.isFinite(exactMatch.stockInLineId) &&
  2072. exactMatch.stockInLineId > 0
  2073. ? { stockInLineId: exactMatch.stockInLineId }
  2074. : {}),
  2075. ...workbenchScanPickQtyFromLot(exactMatch),
  2076. storeId: fgPickOrders?.[0]?.storeId ?? null,
  2077. userId: currentUserId ?? 1,
  2078. });
  2079. const apiTime = performance.now() - apiStartTime;
  2080. console.log(` [API CALL END] Total API time: ${apiTime.toFixed(2)}ms (${(apiTime / 1000).toFixed(3)}s)`);
  2081. console.log(` [API CALL] API end time: ${new Date().toISOString()}`);
  2082. const ok = res.code === "SUCCESS";
  2083. if (ok) {
  2084. clearWorkbenchScanReject(Number(exactMatch.stockOutLineId));
  2085. const entity = res.entity as any;
  2086. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  2087. const nextQty =
  2088. entity?.qty != null ? Number(entity.qty) : undefined;
  2089. // ✅ Batch state updates using startTransition
  2090. const stateUpdateStartTime = performance.now();
  2091. startTransition(() => {
  2092. setQrScanError(false);
  2093. setQrScanSuccess(true);
  2094. setCombinedLotData(prev => prev.map(lot => {
  2095. if (lot.stockOutLineId === exactMatch.stockOutLineId &&
  2096. lot.pickOrderLineId === exactMatch.pickOrderLineId) {
  2097. return {
  2098. ...lot,
  2099. stockOutLineStatus: nextStatus,
  2100. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  2101. };
  2102. }
  2103. return lot;
  2104. }));
  2105. setOriginalCombinedData(prev => prev.map(lot => {
  2106. if (lot.stockOutLineId === exactMatch.stockOutLineId &&
  2107. lot.pickOrderLineId === exactMatch.pickOrderLineId) {
  2108. return {
  2109. ...lot,
  2110. stockOutLineStatus: nextStatus,
  2111. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  2112. };
  2113. }
  2114. return lot;
  2115. }));
  2116. });
  2117. const stateUpdateTime = performance.now() - stateUpdateStartTime;
  2118. console.log(` [PERF] State update time: ${stateUpdateTime.toFixed(2)}ms`);
  2119. // Mark this SOL as processed (same physical lot may still be needed by another SOL)
  2120. const markProcessedStartTime = performance.now();
  2121. const nextProcessedExact = markProcessedStockOutLine(
  2122. processedQrCombinations,
  2123. scannedItemId,
  2124. exactMatch.stockOutLineId,
  2125. );
  2126. setProcessedQrCombinations(nextProcessedExact);
  2127. maybeReleaseQrForNextSol(
  2128. scannedItemId,
  2129. scannedStockInLineId,
  2130. nextProcessedExact,
  2131. );
  2132. const markProcessedTime = performance.now() - markProcessedStartTime;
  2133. console.log(` [PERF] Mark processed time: ${markProcessedTime.toFixed(2)}ms`);
  2134. if (workbenchMode) {
  2135. await refreshWorkbenchAfterScanPick();
  2136. }
  2137. const totalTime = performance.now() - totalStartTime;
  2138. console.log(`✅ [PROCESS OUTSIDE QR END] Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`);
  2139. console.log(` End time: ${new Date().toISOString()}`);
  2140. console.log(`📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.toFixed(2)}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed(2)}ms, api=${apiTime.toFixed(2)}ms, stateUpdate=${stateUpdateTime.toFixed(2)}ms, markProcessed=${markProcessedTime.toFixed(2)}ms`);
  2141. console.log(
  2142. workbenchMode
  2143. ? "✅ Workbench scan-pick: list refreshed from server"
  2144. : "✅ Status updated locally, no full data refresh needed",
  2145. );
  2146. } else {
  2147. console.warn("Unexpected response code from backend:", res.code);
  2148. const failMsg =
  2149. (res as { message?: string })?.message ||
  2150. t("Workbench scan-pick failed.");
  2151. if (
  2152. shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
  2153. exactMatch
  2154. ) {
  2155. openWorkbenchLotLabelModalForLot(exactMatch, failMsg, {
  2156. markAsScanIssue: true,
  2157. });
  2158. return;
  2159. }
  2160. if (workbenchMode && exactMatch.stockOutLineId != null) {
  2161. rememberWorkbenchScanReject(Number(exactMatch.stockOutLineId), failMsg);
  2162. }
  2163. startTransition(() => {
  2164. setQrScanError(true);
  2165. setQrScanSuccess(false);
  2166. setQrScanErrorMsg(failMsg);
  2167. });
  2168. }
  2169. } catch (e) {
  2170. const totalTime = performance.now() - totalStartTime;
  2171. console.error(`❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed(2)}ms`);
  2172. console.error("Error calling updateStockOutLineStatusByQRCodeAndLotNo:", e);
  2173. startTransition(() => {
  2174. setQrScanError(true);
  2175. setQrScanSuccess(false);
  2176. });
  2177. }
  2178. return; // ✅ 直接返回,不需要确认表单
  2179. }
  2180. // ✅ Case 2: itemId 匹配但 stockInLineId 不匹配
  2181. // Workbench 策略:不彈窗,直接切換到掃到的批次並提交一次掃描
  2182. const mismatchCheckStartTime = performance.now();
  2183. if (
  2184. !canProcessWorkbenchScanPick(
  2185. indexes,
  2186. scannedItemId,
  2187. scannedStockInLineId,
  2188. processedQrCombinations,
  2189. )
  2190. ) {
  2191. const mismatchCheckTime = performance.now() - mismatchCheckStartTime;
  2192. const itemRows = indexes.byItemId.get(scannedItemId) ?? [];
  2193. const errMsg =
  2194. itemRows.length === 0
  2195. ? t("QR code does not match any item in current orders.")
  2196. : t(
  2197. "No pending pick line left for this item. It may already be completed or fully processed.",
  2198. );
  2199. console.log(
  2200. ` [SKIP] No pending stock-out line left for mismatch path (check time: ${mismatchCheckTime.toFixed(2)}ms)`,
  2201. );
  2202. startTransition(() => {
  2203. setQrScanError(true);
  2204. setQrScanSuccess(false);
  2205. setQrScanErrorMsg(errMsg);
  2206. });
  2207. return;
  2208. }
  2209. const mismatchCheckTime = performance.now() - mismatchCheckStartTime;
  2210. console.log(` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`);
  2211. const expectedLotStartTime = performance.now();
  2212. const expectedLot = pickExpectedLotForSubstitution(
  2213. activeSuggestedLots,
  2214. processedQrCombinations,
  2215. );
  2216. if (!expectedLot) {
  2217. console.error("Could not determine expected lot for auto-switch");
  2218. startTransition(() => {
  2219. setQrScanError(true);
  2220. setQrScanSuccess(false);
  2221. });
  2222. return;
  2223. }
  2224. const expectedLotTime = performance.now() - expectedLotStartTime;
  2225. console.log(` [PERF] Get expected lot time: ${expectedLotTime.toFixed(2)}ms`);
  2226. console.log(
  2227. `⚠️ Lot mismatch (auto): Expected stockInLineId=${expectedLot.stockInLineId}, Scanned stockInLineId=${scannedStockInLineId}`,
  2228. );
  2229. // 1) 先把掃到的 stockInLineId 轉成 lotNo(workbenchScanPick 需要 lotNo)
  2230. let scannedLotNo: string | null = null;
  2231. try {
  2232. const info = await fetchStockInLineInfoCached(scannedStockInLineId);
  2233. scannedLotNo = info?.lotNo || null;
  2234. } catch (e) {
  2235. console.warn("Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e);
  2236. }
  2237. if (!scannedLotNo) {
  2238. const msg = t("Cannot resolve lot number from QR. Please rescan or use manual confirmation.");
  2239. setQrScanError(true);
  2240. setQrScanSuccess(false);
  2241. setQrScanErrorMsg(msg);
  2242. return;
  2243. }
  2244. // 2) 非 workbench:先 confirmLotSubstitution;workbench 僅依 scan-pick 規則與錯誤訊息
  2245. if (!workbenchMode) {
  2246. const substitutionResult = await confirmLotSubstitution({
  2247. pickOrderLineId: expectedLot.pickOrderLineId,
  2248. stockOutLineId: expectedLot.stockOutLineId,
  2249. originalSuggestedPickLotId: expectedLot.suggestedPickLotId,
  2250. newInventoryLotNo: "",
  2251. newStockInLineId: scannedStockInLineId,
  2252. });
  2253. const substitutionCode = (substitutionResult as any)?.code;
  2254. const switchedToUnavailable =
  2255. substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE";
  2256. if (!substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable)) {
  2257. const errMsg =
  2258. substitutionResult?.message ||
  2259. t("Lot switch failed; pick line was not updated.");
  2260. setQrScanError(true);
  2261. setQrScanSuccess(false);
  2262. setQrScanErrorMsg(errMsg);
  2263. if (expectedLot.stockOutLineId != null) {
  2264. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), errMsg);
  2265. }
  2266. return;
  2267. }
  2268. }
  2269. // 3) 提交掃描(workbench:直接 workbenchScanPick)
  2270. try {
  2271. const res = await workbenchScanPick({
  2272. stockOutLineId: expectedLot.stockOutLineId,
  2273. lotNo: scannedLotNo,
  2274. ...(typeof scannedStockInLineId === "number" &&
  2275. Number.isFinite(scannedStockInLineId) &&
  2276. scannedStockInLineId > 0
  2277. ? { stockInLineId: scannedStockInLineId }
  2278. : {}),
  2279. ...workbenchScanPickQtyFromLot(expectedLot),
  2280. storeId: fgPickOrders?.[0]?.storeId ?? null,
  2281. userId: currentUserId ?? 1,
  2282. });
  2283. const ok = res.code === "SUCCESS";
  2284. if (!ok) {
  2285. const failMsg =
  2286. (res as { message?: string })?.message ||
  2287. t("Workbench scan-pick failed.");
  2288. if (
  2289. shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
  2290. expectedLot
  2291. ) {
  2292. openWorkbenchLotLabelModalForLot(expectedLot, failMsg, {
  2293. markAsScanIssue: true,
  2294. });
  2295. return;
  2296. }
  2297. if (workbenchMode && expectedLot.stockOutLineId != null) {
  2298. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), failMsg);
  2299. }
  2300. setQrScanError(true);
  2301. setQrScanSuccess(false);
  2302. setQrScanErrorMsg(failMsg);
  2303. return;
  2304. }
  2305. clearWorkbenchScanReject(Number(expectedLot.stockOutLineId));
  2306. const entity = res.entity as any;
  2307. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  2308. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  2309. startTransition(() => {
  2310. setQrScanError(false);
  2311. setQrScanSuccess(true);
  2312. setCombinedLotData((prev) =>
  2313. prev.map((lot) => {
  2314. if (
  2315. lot.stockOutLineId === expectedLot.stockOutLineId &&
  2316. lot.pickOrderLineId === expectedLot.pickOrderLineId
  2317. ) {
  2318. return {
  2319. ...lot,
  2320. lotNo: scannedLotNo,
  2321. stockOutLineStatus: nextStatus,
  2322. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  2323. };
  2324. }
  2325. return lot;
  2326. }),
  2327. );
  2328. setOriginalCombinedData((prev) =>
  2329. prev.map((lot) => {
  2330. if (
  2331. lot.stockOutLineId === expectedLot.stockOutLineId &&
  2332. lot.pickOrderLineId === expectedLot.pickOrderLineId
  2333. ) {
  2334. return {
  2335. ...lot,
  2336. lotNo: scannedLotNo,
  2337. stockOutLineStatus: nextStatus,
  2338. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  2339. };
  2340. }
  2341. return lot;
  2342. }),
  2343. );
  2344. });
  2345. const nextProcessedMismatch = markProcessedStockOutLine(
  2346. processedQrCombinations,
  2347. scannedItemId,
  2348. expectedLot.stockOutLineId,
  2349. );
  2350. setProcessedQrCombinations(nextProcessedMismatch);
  2351. maybeReleaseQrForNextSol(
  2352. scannedItemId,
  2353. scannedStockInLineId,
  2354. nextProcessedMismatch,
  2355. );
  2356. if (workbenchMode) {
  2357. await refreshWorkbenchAfterScanPick();
  2358. }
  2359. } catch (e) {
  2360. console.error("Auto-switch scanPick failed:", e);
  2361. setQrScanError(true);
  2362. setQrScanSuccess(false);
  2363. return;
  2364. }
  2365. const totalTime = performance.now() - totalStartTime;
  2366. console.log(
  2367. `✅ [PROCESS OUTSIDE QR AUTO-SWITCH] Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`,
  2368. );
  2369. console.log(` End time: ${new Date().toISOString()}`);
  2370. console.log(
  2371. `📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.toFixed(2)}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed(2)}ms, mismatchCheck=${mismatchCheckTime.toFixed(2)}ms, expectedLot=${expectedLotTime.toFixed(2)}ms`,
  2372. );
  2373. } catch (error) {
  2374. const totalTime = performance.now() - totalStartTime;
  2375. console.error(`❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed(2)}ms`);
  2376. console.error("Error during QR code processing:", error);
  2377. startTransition(() => {
  2378. setQrScanError(true);
  2379. setQrScanSuccess(false);
  2380. });
  2381. return;
  2382. }
  2383. } finally {
  2384. qrPickInFlightRef.current = false;
  2385. }
  2386. }, [
  2387. lotDataIndexes,
  2388. processedQrCombinations,
  2389. combinedLotData,
  2390. fetchStockInLineInfoCached,
  2391. workbenchMode,
  2392. currentUserId,
  2393. clearWorkbenchScanReject,
  2394. rememberWorkbenchScanReject,
  2395. refreshWorkbenchAfterScanPick,
  2396. workbenchScanPickQtyFromLot,
  2397. openWorkbenchLotLabelModalForLot,
  2398. shouldOpenWorkbenchLotLabelModalForFailure,
  2399. t,
  2400. ]);
  2401. // Store processOutsideQrCode in ref for immediate access (update on every render)
  2402. processOutsideQrCodeRef.current = processOutsideQrCode;
  2403. useEffect(() => {
  2404. // Skip if scanner is not active or no data available
  2405. if (!isManualScanning || qrValues.length === 0 || combinedLotData.length === 0 || isRefreshingData) {
  2406. return;
  2407. }
  2408. // Only react to newly appended QR events — not effect re-runs after refresh / index updates.
  2409. if (qrValues.length <= lastConsumedQrValuesLengthRef.current) {
  2410. console.log(
  2411. ` [QR PROCESS] Skipping — no new QR append (len=${qrValues.length}, consumed=${lastConsumedQrValuesLengthRef.current})`,
  2412. );
  2413. return;
  2414. }
  2415. // Don't consume yet if a pick is in flight; retry after refresh / inFlight clears.
  2416. if (qrPickInFlightRef.current) {
  2417. console.log(
  2418. ` [QR PROCESS] Deferring new QR — scan-pick still in flight (len=${qrValues.length})`,
  2419. );
  2420. return;
  2421. }
  2422. const qrValuesChangeStartTime = performance.now();
  2423. console.log(` [QR VALUES EFFECT] Triggered at: ${new Date().toISOString()}`);
  2424. console.log(` [QR VALUES EFFECT] qrValues.length: ${qrValues.length}`);
  2425. console.log(` [QR VALUES EFFECT] qrValues:`, qrValues);
  2426. const latestQr = qrValues[qrValues.length - 1];
  2427. // Claim this append immediately so refresh/deps re-entry cannot re-process it.
  2428. lastConsumedQrValuesLengthRef.current = qrValues.length;
  2429. console.log(` [QR VALUES EFFECT] Latest QR: ${latestQr}`);
  2430. console.log(` [QR VALUES EFFECT] Latest QR detected at: ${new Date().toISOString()}`);
  2431. // ✅ FIXED: Handle test shortcut {2fitestx,y} or {2fittestx,y} where x=itemId, y=stockInLineId
  2432. // Support both formats: {2fitest (2 t's) and {2fittest (3 t's)
  2433. if ((latestQr.startsWith("{2fitest") || latestQr.startsWith("{2fittest")) && latestQr.endsWith("}")) {
  2434. // Extract content: remove "{2fitest" or "{2fittest" and "}"
  2435. let content = '';
  2436. if (latestQr.startsWith("{2fittest")) {
  2437. content = latestQr.substring(9, latestQr.length - 1); // Remove "{2fittest" and "}"
  2438. } else if (latestQr.startsWith("{2fitest")) {
  2439. content = latestQr.substring(8, latestQr.length - 1); // Remove "{2fitest" and "}"
  2440. }
  2441. const parts = content.split(',');
  2442. if (parts.length === 2) {
  2443. const itemId = parseInt(parts[0].trim(), 10);
  2444. const stockInLineId = parseInt(parts[1].trim(), 10);
  2445. if (!isNaN(itemId) && !isNaN(stockInLineId)) {
  2446. console.log(
  2447. `%c TEST QR: Detected ${latestQr.substring(0, 9)}... - Simulating QR input (itemId=${itemId}, stockInLineId=${stockInLineId})`,
  2448. "color: purple; font-weight: bold"
  2449. );
  2450. // ✅ Simulate QR code JSON format
  2451. const simulatedQr = JSON.stringify({
  2452. itemId: itemId,
  2453. stockInLineId: stockInLineId
  2454. });
  2455. console.log(` [TEST QR] Simulated QR content: ${simulatedQr}`);
  2456. console.log(` [TEST QR] Start time: ${new Date().toISOString()}`);
  2457. const testStartTime = performance.now();
  2458. // ✅ Mark as processed FIRST to avoid duplicate processing
  2459. lastProcessedQrRef.current = latestQr;
  2460. processedQrCodesRef.current.add(latestQr);
  2461. if (processedQrCodesRef.current.size > 100) {
  2462. const firstValue = processedQrCodesRef.current.values().next().value;
  2463. if (firstValue !== undefined) {
  2464. processedQrCodesRef.current.delete(firstValue);
  2465. }
  2466. }
  2467. setLastProcessedQr(latestQr);
  2468. setProcessedQrCodes(new Set(processedQrCodesRef.current));
  2469. // ✅ Process immediately (bypass QR scanner delay)
  2470. if (processOutsideQrCodeRef.current) {
  2471. processOutsideQrCodeRef.current(simulatedQr, qrValues.length).then(() => {
  2472. const testTime = performance.now() - testStartTime;
  2473. console.log(` [TEST QR] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`);
  2474. console.log(` [TEST QR] End time: ${new Date().toISOString()}`);
  2475. // Release test shortcut string if another SOL still needs this lot
  2476. if (
  2477. hasPendingActiveRowForStockInLine(
  2478. lotDataIndexes,
  2479. itemId,
  2480. stockInLineId,
  2481. processedQrCombinations,
  2482. )
  2483. ) {
  2484. processedQrCodesRef.current.delete(latestQr);
  2485. if (lastProcessedQrRef.current === latestQr) {
  2486. lastProcessedQrRef.current = "";
  2487. }
  2488. setProcessedQrCodes(new Set(processedQrCodesRef.current));
  2489. setLastProcessedQr("");
  2490. }
  2491. }).catch((error) => {
  2492. const testTime = performance.now() - testStartTime;
  2493. console.error(`❌ [TEST QR] Error after ${testTime.toFixed(2)}ms:`, error);
  2494. });
  2495. }
  2496. // Reset scan
  2497. if (resetScanRef.current) {
  2498. resetScanRef.current();
  2499. }
  2500. const qrValuesChangeTime = performance.now() - qrValuesChangeStartTime;
  2501. console.log(` [QR VALUES EFFECT] Test QR handling time: ${qrValuesChangeTime.toFixed(2)}ms`);
  2502. return; // ✅ IMPORTANT: Return early to prevent normal processing
  2503. } else {
  2504. console.warn(` [TEST QR] Invalid itemId or stockInLineId: itemId=${parts[0]}, stockInLineId=${parts[1]}`);
  2505. }
  2506. } else {
  2507. console.warn(` [TEST QR] Invalid format. Expected {2fitestx,y} or {2fittestx,y}, got: ${latestQr}`);
  2508. }
  2509. }
  2510. // Skip processing if manual confirmation modal is open
  2511. if (manualLotConfirmationOpen) {
  2512. // Check if this is a different QR code than what triggered the modal
  2513. const modalTriggerQr = lastProcessedQrRef.current;
  2514. if (latestQr === modalTriggerQr) {
  2515. console.log(` [QR PROCESS] Skipping - manual modal open for same QR`);
  2516. return;
  2517. }
  2518. // If it's a different QR, allow processing
  2519. console.log(` [QR PROCESS] Different QR detected while manual modal open, allowing processing`);
  2520. }
  2521. const qrDetectionStartTime = performance.now();
  2522. console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`);
  2523. //console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`);
  2524. console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`);
  2525. const qrPayload = parseWorkbenchScanQrPayload(latestQr);
  2526. const canRetrySamePhysicalLot =
  2527. qrPayload != null &&
  2528. hasPendingActiveRowForStockInLine(
  2529. lotDataIndexes,
  2530. qrPayload.itemId,
  2531. qrPayload.stockInLineId,
  2532. processedQrCombinations,
  2533. );
  2534. // Skip if already processed (allow same QR when another pending SOL needs this lot)
  2535. const checkProcessedStartTime = performance.now();
  2536. if (
  2537. (processedQrCodesRef.current.has(latestQr) ||
  2538. lastProcessedQrRef.current === latestQr) &&
  2539. !canRetrySamePhysicalLot
  2540. ) {
  2541. const checkTime = performance.now() - checkProcessedStartTime;
  2542. console.log(` [QR PROCESS] Already processed check time: ${checkTime.toFixed(2)}ms`);
  2543. return;
  2544. }
  2545. if (canRetrySamePhysicalLot) {
  2546. processedQrCodesRef.current.delete(latestQr);
  2547. if (lastProcessedQrRef.current === latestQr) {
  2548. lastProcessedQrRef.current = "";
  2549. }
  2550. }
  2551. const checkTime = performance.now() - checkProcessedStartTime;
  2552. console.log(` [QR PROCESS] Not processed check time: ${checkTime.toFixed(2)}ms`);
  2553. // Handle special shortcut
  2554. if (latestQr === "{2fic}") {
  2555. console.log(" Detected {2fic} shortcut - opening manual lot confirmation form");
  2556. setManualLotConfirmationOpen(true);
  2557. if (resetScanRef.current) {
  2558. resetScanRef.current();
  2559. }
  2560. lastProcessedQrRef.current = latestQr;
  2561. processedQrCodesRef.current.add(latestQr);
  2562. if (processedQrCodesRef.current.size > 100) {
  2563. const firstValue = processedQrCodesRef.current.values().next().value;
  2564. if (firstValue !== undefined) {
  2565. processedQrCodesRef.current.delete(firstValue);
  2566. }
  2567. }
  2568. setLastProcessedQr(latestQr);
  2569. setProcessedQrCodes(prev => {
  2570. const newSet = new Set(prev);
  2571. newSet.add(latestQr);
  2572. if (newSet.size > 100) {
  2573. const firstValue = newSet.values().next().value;
  2574. if (firstValue !== undefined) {
  2575. newSet.delete(firstValue);
  2576. }
  2577. }
  2578. return newSet;
  2579. });
  2580. return;
  2581. }
  2582. // Process new QR code immediately (background mode - no modal)
  2583. if (latestQr && (latestQr !== lastProcessedQrRef.current || canRetrySamePhysicalLot)) {
  2584. const processingStartTime = performance.now();
  2585. console.log(` [QR PROCESS] Starting processing at: ${new Date().toISOString()}`);
  2586. console.log(` [QR PROCESS] Time since detection: ${(processingStartTime - qrDetectionStartTime).toFixed(2)}ms`);
  2587. // ✅ Process immediately for better responsiveness
  2588. // Clear any pending debounced processing
  2589. if (qrProcessingTimeoutRef.current) {
  2590. clearTimeout(qrProcessingTimeoutRef.current);
  2591. qrProcessingTimeoutRef.current = null;
  2592. }
  2593. // Log immediately (console.log is synchronous)
  2594. console.log(` [QR PROCESS] Processing new QR code with enhanced validation: ${latestQr}`);
  2595. // Update refs immediately (no state update delay) - do this FIRST
  2596. const refUpdateStartTime = performance.now();
  2597. lastProcessedQrRef.current = latestQr;
  2598. processedQrCodesRef.current.add(latestQr);
  2599. if (processedQrCodesRef.current.size > 100) {
  2600. const firstValue = processedQrCodesRef.current.values().next().value;
  2601. if (firstValue !== undefined) {
  2602. processedQrCodesRef.current.delete(firstValue);
  2603. }
  2604. }
  2605. const refUpdateTime = performance.now() - refUpdateStartTime;
  2606. console.log(` [QR PROCESS] Ref update time: ${refUpdateTime.toFixed(2)}ms`);
  2607. // Process immediately in background - no modal/form needed, no delays
  2608. // Use ref to avoid dependency issues
  2609. const processCallStartTime = performance.now();
  2610. if (processOutsideQrCodeRef.current) {
  2611. processOutsideQrCodeRef.current(latestQr, qrValues.length)
  2612. .then(() => {
  2613. const processCallTime = performance.now() - processCallStartTime;
  2614. const totalProcessingTime = performance.now() - processingStartTime;
  2615. console.log(` [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`);
  2616. console.log(` [QR PROCESS] Total processing time: ${totalProcessingTime.toFixed(2)}ms (${(totalProcessingTime / 1000).toFixed(3)}s)`);
  2617. // maybeReleaseQrForNextSol may have cleared refs; sync UI state
  2618. setProcessedQrCodes(new Set(processedQrCodesRef.current));
  2619. setLastProcessedQr(lastProcessedQrRef.current);
  2620. })
  2621. .catch((error) => {
  2622. const processCallTime = performance.now() - processCallStartTime;
  2623. const totalProcessingTime = performance.now() - processingStartTime;
  2624. console.error(`❌ [QR PROCESS] processOutsideQrCode error after ${processCallTime.toFixed(2)}ms:`, error);
  2625. console.error(`❌ [QR PROCESS] Total processing time before error: ${totalProcessingTime.toFixed(2)}ms`);
  2626. });
  2627. }
  2628. // Update state for UI (but don't block on it)
  2629. const stateUpdateStartTime = performance.now();
  2630. setLastProcessedQr(latestQr);
  2631. setProcessedQrCodes(new Set(processedQrCodesRef.current));
  2632. const stateUpdateTime = performance.now() - stateUpdateStartTime;
  2633. console.log(` [QR PROCESS] State update time: ${stateUpdateTime.toFixed(2)}ms`);
  2634. const detectionTime = performance.now() - qrDetectionStartTime;
  2635. const totalEffectTime = performance.now() - qrValuesChangeStartTime;
  2636. console.log(` [QR DETECTION] Total detection time: ${detectionTime.toFixed(2)}ms`);
  2637. console.log(` [QR VALUES EFFECT] Total effect time: ${totalEffectTime.toFixed(2)}ms`);
  2638. }
  2639. return () => {
  2640. if (qrProcessingTimeoutRef.current) {
  2641. clearTimeout(qrProcessingTimeoutRef.current);
  2642. qrProcessingTimeoutRef.current = null;
  2643. }
  2644. };
  2645. }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, manualLotConfirmationOpen, lotDataIndexes, processedQrCombinations]);
  2646. const renderCountRef = useRef(0);
  2647. const renderStartTimeRef = useRef<number | null>(null);
  2648. // Track render performance
  2649. useEffect(() => {
  2650. renderCountRef.current++;
  2651. const now = performance.now();
  2652. if (renderStartTimeRef.current !== null) {
  2653. const renderTime = now - renderStartTimeRef.current;
  2654. if (renderTime > 100) { // Only log slow renders (>100ms)
  2655. console.log(` [PERF] Render #${renderCountRef.current} took ${renderTime.toFixed(2)}ms, combinedLotData length: ${combinedLotData.length}`);
  2656. }
  2657. renderStartTimeRef.current = null;
  2658. }
  2659. }, [combinedLotData.length]);
  2660. // Auto-start scanner only once on mount
  2661. const scannerInitializedRef = useRef(false);
  2662. useEffect(() => {
  2663. if (session && currentUserId && !initializationRef.current) {
  2664. console.log(" Session loaded, initializing pick order...");
  2665. initializationRef.current = true;
  2666. // Only fetch existing data, no auto-assignment
  2667. fetchAllCombinedLotData();
  2668. }
  2669. }, [session, currentUserId, fetchAllCombinedLotData]);
  2670. // Separate effect for auto-starting scanner (only once, prevents multiple resets)
  2671. useEffect(() => {
  2672. if (session && currentUserId && !scannerInitializedRef.current) {
  2673. scannerInitializedRef.current = true;
  2674. // ✅ Auto-start scanner on mount for tablet use (background mode - no modal)
  2675. console.log("✅ Auto-starting QR scanner in background mode");
  2676. setIsManualScanning(true);
  2677. startScan();
  2678. }
  2679. }, [session, currentUserId, startScan]);
  2680. // Add event listener for manual assignment
  2681. useEffect(() => {
  2682. const handlePickOrderAssigned = () => {
  2683. console.log("🔄 Pick order assigned event received, refreshing data...");
  2684. fetchAllCombinedLotData();
  2685. };
  2686. window.addEventListener('pickOrderAssigned', handlePickOrderAssigned);
  2687. return () => {
  2688. window.removeEventListener('pickOrderAssigned', handlePickOrderAssigned);
  2689. };
  2690. }, [fetchAllCombinedLotData]);
  2691. const handleManualInputSubmit = useCallback(() => {
  2692. if (qrScanInput.trim() !== '') {
  2693. handleQrCodeSubmit(qrScanInput.trim());
  2694. }
  2695. }, [qrScanInput, handleQrCodeSubmit]);
  2696. // Handle QR code submission from modal (internal scanning)
  2697. const handleQrCodeSubmitFromModal = useCallback(async () => {
  2698. // Legacy path: marked SOL as `checked` (normal version). Disabled for workbench.
  2699. setQrScanError(true);
  2700. setQrScanSuccess(false);
  2701. setQrScanErrorMsg(t("Workbench uses scan-pick; this QR modal flow is not supported."));
  2702. }, [t]);
  2703. const handlePickQtyChange = useCallback((lotKey: string, value: number | string) => {
  2704. if (value === '' || value === null || value === undefined) {
  2705. setPickQtyData((prev) => {
  2706. if (!Object.prototype.hasOwnProperty.call(prev, lotKey)) return prev;
  2707. const next = { ...prev };
  2708. delete next[lotKey];
  2709. return next;
  2710. });
  2711. return;
  2712. }
  2713. const numericValue = typeof value === "string" ? parseFloat(value) : value;
  2714. if (Number.isNaN(numericValue) || numericValue < 0) {
  2715. setPickQtyData((prev) => {
  2716. if (!Object.prototype.hasOwnProperty.call(prev, lotKey)) return prev;
  2717. const next = { ...prev };
  2718. delete next[lotKey];
  2719. return next;
  2720. });
  2721. return;
  2722. }
  2723. setPickQtyData((prev) => ({
  2724. ...prev,
  2725. [lotKey]: numericValue,
  2726. }));
  2727. }, []);
  2728. const [autoAssignStatus, setAutoAssignStatus] = useState<'idle' | 'checking' | 'assigned' | 'no_orders'>('idle');
  2729. const [autoAssignMessage, setAutoAssignMessage] = useState<string>('');
  2730. const [completionStatus, setCompletionStatus] = useState<PickOrderCompletionResponse | null>(null);
  2731. const checkAndAutoAssignNext = useCallback(async () => {
  2732. if (!currentUserId) return;
  2733. try {
  2734. const completionResponse = await checkPickOrderCompletion(currentUserId);
  2735. if (completionResponse.code === "SUCCESS" && completionResponse.entity?.hasCompletedOrders) {
  2736. console.log("Found completed pick orders, auto-assigning next...");
  2737. // 移除前端的自动分配逻辑,因为后端已经处理了
  2738. // await handleAutoAssignAndRelease(); // 删除这个函数
  2739. }
  2740. } catch (error) {
  2741. console.error("Error checking pick order completion:", error);
  2742. }
  2743. }, [currentUserId]);
  2744. // Handle reject lot
  2745. // Handle pick execution form
  2746. const handlePickExecutionForm = useCallback((lot: any) => {
  2747. console.log("=== Pick Execution Form ===");
  2748. console.log("Lot data:", lot);
  2749. if (!lot) {
  2750. console.warn("No lot data provided for pick execution form");
  2751. return;
  2752. }
  2753. console.log("Opening pick execution form for lot:", lot.lotNo);
  2754. setSelectedLotForExecutionForm(lot);
  2755. setPickExecutionFormOpen(true);
  2756. console.log("Pick execution form opened for lot ID:", lot.lotId);
  2757. }, []);
  2758. // Calculate remaining required quantity
  2759. const calculateRemainingRequiredQty = useCallback((lot: any) => {
  2760. const requiredQty = lot.requiredQty || 0;
  2761. const stockOutLineQty = lot.stockOutLineQty || 0;
  2762. return Math.max(0, requiredQty - stockOutLineQty);
  2763. }, []);
  2764. // Search criteria
  2765. const searchCriteria: Criterion<any>[] = [
  2766. {
  2767. label: t("Pick Order Code"),
  2768. paramName: "pickOrderCode",
  2769. type: "text",
  2770. },
  2771. {
  2772. label: t("Item Code"),
  2773. paramName: "itemCode",
  2774. type: "text",
  2775. },
  2776. {
  2777. label: t("Item Name"),
  2778. paramName: "itemName",
  2779. type: "text",
  2780. },
  2781. {
  2782. label: t("Lot No"),
  2783. paramName: "lotNo",
  2784. type: "text",
  2785. },
  2786. ];
  2787. const handleSearch = useCallback((query: Record<string, any>) => {
  2788. setSearchQuery({ ...query });
  2789. console.log("Search query:", query);
  2790. if (!originalCombinedData) return;
  2791. const filtered = originalCombinedData.filter((lot: any) => {
  2792. const pickOrderCodeMatch = !query.pickOrderCode ||
  2793. lot.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase());
  2794. const itemCodeMatch = !query.itemCode ||
  2795. lot.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase());
  2796. const itemNameMatch = !query.itemName ||
  2797. lot.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase());
  2798. const lotNoMatch = !query.lotNo ||
  2799. lot.lotNo?.toLowerCase().includes((query.lotNo || "").toLowerCase());
  2800. return pickOrderCodeMatch && itemCodeMatch && itemNameMatch && lotNoMatch;
  2801. });
  2802. setCombinedLotData(filtered);
  2803. console.log("Filtered lots count:", filtered.length);
  2804. }, [originalCombinedData]);
  2805. const handleReset = useCallback(() => {
  2806. setSearchQuery({});
  2807. if (originalCombinedData) {
  2808. setCombinedLotData(originalCombinedData);
  2809. }
  2810. }, [originalCombinedData]);
  2811. const handlePageChange = useCallback((event: unknown, newPage: number) => {
  2812. setPaginationController(prev => ({
  2813. ...prev,
  2814. pageNum: newPage,
  2815. }));
  2816. }, []);
  2817. const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
  2818. const newPageSize = parseInt(event.target.value, 10);
  2819. setPaginationController({
  2820. pageNum: 0,
  2821. pageSize: newPageSize === -1 ? -1 : newPageSize,
  2822. });
  2823. }, []);
  2824. // ✅ Workbench list: group same item (within same route), sort completed lots first,
  2825. // and suppress repeated item fields on subsequent rows.
  2826. const paginatedData = useMemo(() => {
  2827. type RowMeta = {
  2828. lot: any;
  2829. isGroupFirst: boolean;
  2830. groupDisplayIndex: number;
  2831. };
  2832. const isCompletedStatus = (lot: any) => {
  2833. const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
  2834. return (
  2835. st === "completed" ||
  2836. st === "partially_completed" ||
  2837. st === "partially_complete"
  2838. );
  2839. };
  2840. const isCheckedStatus = (lot: any) => {
  2841. const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
  2842. return st === "checked";
  2843. };
  2844. const statusRank = (lot: any) => {
  2845. // Desired order within same item:
  2846. // completed -> checked -> pending -> rejected -> others
  2847. const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
  2848. if (isCompletedStatus(lot)) return 0;
  2849. if (isCheckedStatus(lot)) return 1;
  2850. if (st === "pending") return 2;
  2851. if (st === "rejected") return 3;
  2852. return 9;
  2853. };
  2854. // Keep stable group ordering by first appearance.
  2855. const groups = new Map<
  2856. string,
  2857. { firstIndex: number; items: { lot: any; originalIndex: number }[] }
  2858. >();
  2859. combinedLotData.forEach((lot: any, originalIndex: number) => {
  2860. const routeKey = String(lot?.routerRoute ?? "").trim();
  2861. const pickOrderLineKey =
  2862. lot?.pickOrderLineId != null ? `pol:${String(lot.pickOrderLineId)}` : "pol:unknown";
  2863. const itemKey =
  2864. lot?.itemId != null
  2865. ? `itemId:${String(lot.itemId)}`
  2866. : `itemCode:${String(lot?.itemCode ?? "").trim()}`;
  2867. // Group by pickOrderLine first so no-lot row stays with its lot rows even when route is empty.
  2868. const key = `${pickOrderLineKey}__${itemKey}__${routeKey}`;
  2869. const g = groups.get(key);
  2870. if (!g) {
  2871. groups.set(key, { firstIndex: originalIndex, items: [{ lot, originalIndex }] });
  2872. } else {
  2873. g.items.push({ lot, originalIndex });
  2874. }
  2875. });
  2876. const groupEntries = Array.from(groups.values()).sort(
  2877. (a, b) => a.firstIndex - b.firstIndex,
  2878. );
  2879. const flattened: RowMeta[] = [];
  2880. for (let gi = 0; gi < groupEntries.length; gi += 1) {
  2881. const g = groupEntries[gi];
  2882. // Re-number groups contiguously (avoid gaps after grouping)
  2883. const groupDisplayIndex = gi + 1;
  2884. const sortedWithin = [...g.items].sort((a, b) => {
  2885. const ra = statusRank(a.lot);
  2886. const rb = statusRank(b.lot);
  2887. if (ra !== rb) return ra - rb;
  2888. return a.originalIndex - b.originalIndex; // stable fallback
  2889. });
  2890. sortedWithin.forEach((it, idx) => {
  2891. flattened.push({
  2892. lot: it.lot,
  2893. isGroupFirst: idx === 0,
  2894. groupDisplayIndex,
  2895. });
  2896. });
  2897. }
  2898. if (paginationController.pageSize === -1) return flattened;
  2899. const startIndex =
  2900. paginationController.pageNum * paginationController.pageSize;
  2901. const endIndex = startIndex + paginationController.pageSize;
  2902. return flattened.slice(startIndex, endIndex);
  2903. }, [combinedLotData, paginationController.pageNum, paginationController.pageSize]);
  2904. const allItemsReady = useMemo(() => {
  2905. if (combinedLotData.length === 0) return false;
  2906. return combinedLotData.every((lot: any) => {
  2907. const status = lot.stockOutLineStatus?.toLowerCase();
  2908. const isRejected =
  2909. status === 'rejected' || lot.lotAvailability === 'rejected';
  2910. const isCompleted =
  2911. status === 'completed' || status === 'partially_completed' || status === 'partially_complete';
  2912. const isChecked = status === 'checked';
  2913. const isPending = status === 'pending';
  2914. // ✅ FIXED: 无库存(noLot)行:pending 状态也应该被视为 ready(可以提交)
  2915. // ✅ 過期批號(未換批):與 noLot 相同,視為可收尾
  2916. if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) {
  2917. return isChecked || isCompleted || isRejected || isPending;
  2918. }
  2919. // 正常 lot:必须已扫描/提交或者被拒收
  2920. return isChecked || isCompleted || isRejected;
  2921. });
  2922. }, [combinedLotData]);
  2923. const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: number, source: 'justComplete' | 'singleSubmit') => {
  2924. if (!lot.stockOutLineId) {
  2925. console.error("No stock out line found for this lot");
  2926. return;
  2927. }
  2928. const solId = Number(lot.stockOutLineId);
  2929. if (solId > 0 && actionBusyBySolId[solId]) {
  2930. console.warn("Action already in progress for stockOutLineId:", solId);
  2931. return;
  2932. }
  2933. try {
  2934. if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true }));
  2935. const targetUnavailable = isInventoryLotLineUnavailable(lot);
  2936. const effectiveSubmitQty = targetUnavailable && submitQty > 0 ? 0 : submitQty;
  2937. const canonicalLotForSol =
  2938. solId > 0
  2939. ? combinedLotData.find((r) => Number(r.stockOutLineId) === solId) ?? lot
  2940. : lot;
  2941. // Workbench「Just Completed」:不掃 QR,直接用列上的 lotNo + stockInLineId 走 scan-pick 完成庫存扣帳
  2942. if (workbenchMode && source === "justComplete") {
  2943. const solIdForOverride = Number(canonicalLotForSol.stockOutLineId) || 0;
  2944. const lotIdForOverride = canonicalLotForSol.lotId;
  2945. const lotKeyForOverride =
  2946. Number.isFinite(solIdForOverride) && solIdForOverride > 0
  2947. ? `sol:${solIdForOverride}`
  2948. : `${canonicalLotForSol.pickOrderLineId}-${lotIdForOverride}`;
  2949. const hasExplicitSubmitOverride = Object.prototype.hasOwnProperty.call(
  2950. pickQtyData,
  2951. lotKeyForOverride,
  2952. );
  2953. const explicitSubmitOverride = hasExplicitSubmitOverride
  2954. ? Number(pickQtyData[lotKeyForOverride])
  2955. : NaN;
  2956. const qtyPayload = workbenchScanPickQtyFromLot(canonicalLotForSol);
  2957. const wbJustQty = qtyPayload.qty;
  2958. const isUnavailableForJustComplete = isInventoryLotLineUnavailable(canonicalLotForSol);
  2959. const canPostScanPick =
  2960. // unavailable lot: Just Completed must always submit qty=0, even without lotNo
  2961. isUnavailableForJustComplete || (
  2962. canonicalLotForSol.lotNo && String(canonicalLotForSol.lotNo).trim() !== "" && (
  2963. // explicit short submit: user typed 0 (must send qty=0 to backend)
  2964. (hasExplicitSubmitOverride &&
  2965. Number.isFinite(explicitSubmitOverride) &&
  2966. explicitSubmitOverride === 0) ||
  2967. // normal pick: positive qty
  2968. (wbJustQty != null && wbJustQty > 0)
  2969. )
  2970. );
  2971. if (canPostScanPick) {
  2972. const qtyToSend = isUnavailableForJustComplete
  2973. ? 0
  2974. : hasExplicitSubmitOverride && explicitSubmitOverride === 0
  2975. ? 0
  2976. : Number(wbJustQty);
  2977. const res = await workbenchScanPick({
  2978. stockOutLineId: Number(canonicalLotForSol.stockOutLineId),
  2979. lotNo: String(canonicalLotForSol.lotNo).trim(),
  2980. ...(typeof canonicalLotForSol.stockInLineId === "number" &&
  2981. Number.isFinite(canonicalLotForSol.stockInLineId) &&
  2982. canonicalLotForSol.stockInLineId > 0
  2983. ? { stockInLineId: canonicalLotForSol.stockInLineId }
  2984. : {}),
  2985. qty: qtyToSend,
  2986. justComplete: true,
  2987. storeId: fgPickOrders?.[0]?.storeId ?? null,
  2988. userId: currentUserId ?? 1,
  2989. });
  2990. const scanOk = res.code === "SUCCESS";
  2991. if (!scanOk) {
  2992. rememberWorkbenchScanReject(
  2993. Number(canonicalLotForSol.stockOutLineId),
  2994. (res as { message?: string })?.message,
  2995. );
  2996. throw new Error(
  2997. (res as { message?: string })?.message || "Workbench scan-pick failed",
  2998. );
  2999. }
  3000. clearWorkbenchScanReject(Number(canonicalLotForSol.stockOutLineId));
  3001. const entity = res.entity as any;
  3002. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  3003. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  3004. setPickQtyData((prev) => {
  3005. if (!Object.prototype.hasOwnProperty.call(prev, lotKeyForOverride)) return prev;
  3006. const next = { ...prev };
  3007. delete next[lotKeyForOverride];
  3008. return next;
  3009. });
  3010. await refreshWorkbenchAfterScanPick();
  3011. setTimeout(() => {
  3012. checkAndAutoAssignNext();
  3013. }, 1000);
  3014. console.log("Just Completed (workbench): workbenchScanPick posted without QR.");
  3015. return;
  3016. }
  3017. const justCompleteErr = t(
  3018. "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.",
  3019. );
  3020. if (solId > 0) {
  3021. rememberWorkbenchScanReject(solId, justCompleteErr);
  3022. }
  3023. setQrScanErrorMsg(justCompleteErr);
  3024. throw new Error(justCompleteErr);
  3025. }
  3026. if (effectiveSubmitQty === 0 && source === 'singleSubmit') {
  3027. console.log(`=== SUBMITTING ALL ZEROS CASE ===`);
  3028. console.log(`Lot: ${lot.lotNo}`);
  3029. console.log(`Stock Out Line ID: ${lot.stockOutLineId}`);
  3030. // ✅ Workbench: route qty=0 through scan-pick as well (backend supports zero-complete).
  3031. // Allow empty lotNo for noLot/expired/unavailable rows.
  3032. if (workbenchMode) {
  3033. const res = await workbenchScanPick({
  3034. stockOutLineId: Number(lot.stockOutLineId),
  3035. lotNo: String(lot.lotNo ?? "").trim(),
  3036. ...(typeof lot.stockInLineId === "number" &&
  3037. Number.isFinite(lot.stockInLineId) &&
  3038. lot.stockInLineId > 0
  3039. ? { stockInLineId: lot.stockInLineId }
  3040. : {}),
  3041. qty: 0,
  3042. storeId: fgPickOrders?.[0]?.storeId ?? null,
  3043. userId: currentUserId ?? 1,
  3044. });
  3045. const scanOk = res.code === "SUCCESS";
  3046. if (!scanOk) {
  3047. rememberWorkbenchScanReject(
  3048. Number(lot.stockOutLineId),
  3049. (res as { message?: string })?.message,
  3050. );
  3051. throw new Error(
  3052. (res as { message?: string })?.message || "Workbench scan-pick failed (qty=0)",
  3053. );
  3054. }
  3055. clearWorkbenchScanReject(Number(lot.stockOutLineId));
  3056. await refreshWorkbenchAfterScanPick();
  3057. setTimeout(() => {
  3058. checkAndAutoAssignNext();
  3059. }, 1000);
  3060. return;
  3061. }
  3062. // Legacy non-workbench path used `checked` as an intermediate state.
  3063. // Workbench mode is always true in this page; keep this branch unreachable.
  3064. throw new Error("Unsupported legacy checked flow on workbench page");
  3065. }
  3066. // DO Workbench: inventory posting + SOL/POL rules live in /doPickOrder/workbench/scan-pick
  3067. if (
  3068. workbenchMode &&
  3069. effectiveSubmitQty > 0 &&
  3070. lot.lotNo &&
  3071. String(lot.lotNo).trim() !== "" &&
  3072. !isLotAvailabilityExpired(lot) &&
  3073. !isInventoryLotLineUnavailable(lot)
  3074. ) {
  3075. const res = await workbenchScanPick({
  3076. stockOutLineId: Number(lot.stockOutLineId),
  3077. lotNo: String(lot.lotNo).trim(),
  3078. ...(typeof lot.stockInLineId === "number" &&
  3079. Number.isFinite(lot.stockInLineId) &&
  3080. lot.stockInLineId > 0
  3081. ? { stockInLineId: lot.stockInLineId }
  3082. : {}),
  3083. qty: Number(effectiveSubmitQty),
  3084. storeId: fgPickOrders?.[0]?.storeId ?? null,
  3085. userId: currentUserId ?? 1,
  3086. });
  3087. const scanOk = res.code === "SUCCESS";
  3088. if (!scanOk) {
  3089. rememberWorkbenchScanReject(
  3090. Number(lot.stockOutLineId),
  3091. (res as { message?: string })?.message,
  3092. );
  3093. throw new Error(
  3094. (res as { message?: string })?.message || "Workbench scan-pick failed",
  3095. );
  3096. }
  3097. clearWorkbenchScanReject(Number(lot.stockOutLineId));
  3098. const entity = res.entity as any;
  3099. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  3100. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  3101. const successLotKey = getWorkbenchQtyLotKey(lot);
  3102. setPickQtyData((prev) => {
  3103. if (!Object.prototype.hasOwnProperty.call(prev, successLotKey)) return prev;
  3104. const next = { ...prev };
  3105. delete next[successLotKey];
  3106. return next;
  3107. });
  3108. await refreshWorkbenchAfterScanPick();
  3109. setTimeout(() => {
  3110. checkAndAutoAssignNext();
  3111. }, 1000);
  3112. return;
  3113. }
  3114. // FIXED: Calculate cumulative quantity correctly
  3115. const currentActualPickQty = lot.actualPickQty || 0;
  3116. const cumulativeQty = currentActualPickQty + effectiveSubmitQty;
  3117. // FIXED: Determine status based on cumulative quantity vs required quantity
  3118. let newStatus = 'partially_completed';
  3119. if (cumulativeQty >= lot.requiredQty) {
  3120. newStatus = 'completed';
  3121. } else if (cumulativeQty > 0) {
  3122. newStatus = 'partially_completed';
  3123. } else {
  3124. // Legacy non-workbench path used `checked` as an intermediate state.
  3125. // Workbench posts immediately via scan-pick.
  3126. newStatus = 'pending';
  3127. }
  3128. console.log(`=== PICK QUANTITY SUBMISSION DEBUG ===`);
  3129. console.log(`Lot: ${lot.lotNo}`);
  3130. console.log(`Required Qty: ${lot.requiredQty}`);
  3131. console.log(`Current Actual Pick Qty: ${currentActualPickQty}`);
  3132. console.log(`New Submitted Qty: ${effectiveSubmitQty}`);
  3133. console.log(`Cumulative Qty: ${cumulativeQty}`);
  3134. console.log(`New Status: ${newStatus}`);
  3135. console.log(`=====================================`);
  3136. if (!workbenchMode) {
  3137. await updateStockOutLineStatus({
  3138. id: lot.stockOutLineId,
  3139. status: newStatus,
  3140. // 后端 updateStatus 的 qty 是“增量 delta”,不能传 cumulativeQty(否则会重复累加导致 out/hold 大幅偏移)
  3141. qty: effectiveSubmitQty
  3142. });
  3143. applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), newStatus, cumulativeQty);
  3144. }
  3145. // 注意:库存过账(hold->out)与 ledger 由后端 updateStatus 内部统一处理;
  3146. // 前端不再额外调用 updateInventoryLotLineQuantities(operation='pick'),避免 double posting。
  3147. // Workbench completion is handled in backend scan-pick flow.
  3148. void fetchAllCombinedLotData();
  3149. console.log("Pick quantity submitted successfully!");
  3150. setTimeout(() => {
  3151. checkAndAutoAssignNext();
  3152. }, 1000);
  3153. } catch (error) {
  3154. console.error("Error submitting pick quantity:", error);
  3155. setQrScanError(true);
  3156. setQrScanSuccess(false);
  3157. } finally {
  3158. if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false }));
  3159. }
  3160. }, [
  3161. fetchAllCombinedLotData,
  3162. checkAndAutoAssignNext,
  3163. actionBusyBySolId,
  3164. applyLocalStockOutLineUpdate,
  3165. workbenchMode,
  3166. currentUserId,
  3167. rememberWorkbenchScanReject,
  3168. clearWorkbenchScanReject,
  3169. refreshWorkbenchAfterScanPick,
  3170. combinedLotData,
  3171. workbenchScanPickQtyFromLot,
  3172. t,
  3173. ]);
  3174. const handleSkip = useCallback(async (lot: any) => {
  3175. try {
  3176. console.log("Just Complete clicked (workbench: scan-pick without QR when possible):", lot.lotNo);
  3177. await handleSubmitPickQtyWithQty(lot, 0, 'justComplete');
  3178. } catch (err) {
  3179. console.error("Error in Skip:", err);
  3180. }
  3181. }, [handleSubmitPickQtyWithQty]);
  3182. const hasPendingBatchSubmit = useMemo(() => {
  3183. return combinedLotData.some((lot) => {
  3184. const status = String(lot.stockOutLineStatus || "").toLowerCase();
  3185. return status === "pending" || status === "partially_completed" || status === "partially_complete";
  3186. });
  3187. }, [combinedLotData]);
  3188. useEffect(() => {
  3189. if (!hasPendingBatchSubmit) return;
  3190. const handler = (event: BeforeUnloadEvent) => {
  3191. event.preventDefault();
  3192. event.returnValue = "";
  3193. };
  3194. window.addEventListener("beforeunload", handler);
  3195. return () => window.removeEventListener("beforeunload", handler);
  3196. }, [hasPendingBatchSubmit]);
  3197. const handleStartScan = useCallback(() => {
  3198. const startTime = performance.now();
  3199. console.log(` [START SCAN] Called at: ${new Date().toISOString()}`);
  3200. console.log(` [START SCAN] Starting manual QR scan...`);
  3201. setIsManualScanning(true);
  3202. const setManualScanningTime = performance.now() - startTime;
  3203. console.log(` [START SCAN] setManualScanning time: ${setManualScanningTime.toFixed(2)}ms`);
  3204. setProcessedQrCodes(new Set());
  3205. setLastProcessedQr('');
  3206. setProcessedQrCombinations(new Map());
  3207. processedQrCodesRef.current = new Set();
  3208. lastProcessedQrRef.current = '';
  3209. qrPickInFlightRef.current = false;
  3210. // Ignore any QR values already in the buffer; only new scans after Start count.
  3211. lastConsumedQrValuesLengthRef.current = qrValues.length;
  3212. setQrScanError(false);
  3213. setQrScanSuccess(false);
  3214. setQrScanErrorMsg('');
  3215. const beforeStartScanTime = performance.now();
  3216. startScan();
  3217. const startScanTime = performance.now() - beforeStartScanTime;
  3218. console.log(` [START SCAN] startScan() call time: ${startScanTime.toFixed(2)}ms`);
  3219. const totalTime = performance.now() - startTime;
  3220. console.log(` [START SCAN] Total start scan time: ${totalTime.toFixed(2)}ms`);
  3221. console.log(` [START SCAN] Start scan completed at: ${new Date().toISOString()}`);
  3222. }, [startScan, qrValues.length]);
  3223. const handlePickOrderSwitch = useCallback(async (pickOrderId: number) => {
  3224. if (pickOrderSwitching) return;
  3225. setPickOrderSwitching(true);
  3226. try {
  3227. console.log(" Switching to pick order:", pickOrderId);
  3228. setSelectedPickOrderId(pickOrderId);
  3229. // 强制刷新数据,确保显示正确的 pick order 数据
  3230. await fetchAllCombinedLotData(currentUserId, pickOrderId);
  3231. } catch (error) {
  3232. console.error("Error switching pick order:", error);
  3233. } finally {
  3234. setPickOrderSwitching(false);
  3235. }
  3236. }, [pickOrderSwitching, currentUserId, fetchAllCombinedLotData]);
  3237. const handleStopScan = useCallback(() => {
  3238. console.log("⏸️ Pausing QR scanner...");
  3239. setIsManualScanning(false);
  3240. setQrScanError(false);
  3241. setQrScanSuccess(false);
  3242. setQrScanErrorMsg('');
  3243. stopScan();
  3244. resetScan();
  3245. lastConsumedQrValuesLengthRef.current = 0;
  3246. }, [stopScan, resetScan]);
  3247. const handlelotnull = useCallback(async (lot: any) => {
  3248. // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId
  3249. const stockOutLineId = lot.stockOutLineId;
  3250. if (!stockOutLineId) {
  3251. console.error(" No stockOutLineId found for lot:", lot);
  3252. return;
  3253. }
  3254. const solId = Number(stockOutLineId);
  3255. if (solId > 0 && actionBusyBySolId[solId]) {
  3256. console.warn("Action already in progress for stockOutLineId:", solId);
  3257. return;
  3258. }
  3259. try {
  3260. if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true }));
  3261. // Step 1: Update stock out line status
  3262. await updateStockOutLineStatus({
  3263. id: stockOutLineId,
  3264. status: 'completed',
  3265. qty: 0
  3266. });
  3267. // Step 2: Create pick execution issue for no-lot case
  3268. // Get pick order ID from fgPickOrders or use 0 if not available
  3269. const pickOrderId = lot.pickOrderId || fgPickOrders[0]?.pickOrderId || 0;
  3270. const pickOrderCode = lot.pickOrderCode || fgPickOrders[0]?.pickOrderCode || lot.pickOrderConsoCode || '';
  3271. const issueData: PickExecutionIssueData = {
  3272. type: "Do", // Delivery Order type
  3273. pickOrderId: pickOrderId,
  3274. pickOrderCode: pickOrderCode,
  3275. pickOrderCreateDate: dayjs().format('YYYY-MM-DD'), // Use dayjs format
  3276. pickExecutionDate: dayjs().format('YYYY-MM-DD'),
  3277. pickOrderLineId: lot.pickOrderLineId,
  3278. itemId: lot.itemId,
  3279. itemCode: lot.itemCode || '',
  3280. itemDescription: lot.itemName || '',
  3281. lotId: null, // No lot available
  3282. lotNo: null, // No lot number
  3283. storeLocation: lot.location || '',
  3284. requiredQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0,
  3285. actualPickQty: 0, // No items picked (no lot available)
  3286. missQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0, // All quantity is missing
  3287. badItemQty: 0,
  3288. issueRemark: `No lot available for this item. Handled via handlelotnull.`,
  3289. pickerName: session?.user?.name || '',
  3290. };
  3291. const result = await recordPickExecutionIssue(issueData);
  3292. console.log(" Pick execution issue created for no-lot item:", result);
  3293. if (result && result.code === "SUCCESS") {
  3294. console.log(" No-lot item handled and issue recorded successfully");
  3295. } else {
  3296. console.error(" Failed to record pick execution issue:", result);
  3297. }
  3298. // Step 3: Refresh data
  3299. await fetchAllCombinedLotData();
  3300. } catch (error) {
  3301. console.error(" Error in handlelotnull:", error);
  3302. } finally {
  3303. if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false }));
  3304. }
  3305. }, [fetchAllCombinedLotData, session, currentUserId, fgPickOrders, actionBusyBySolId]);
  3306. const handleBatchScan = useCallback(async () => {
  3307. const startTime = performance.now();
  3308. console.log(` [BATCH SCAN START]`);
  3309. console.log(` Start time: ${new Date().toISOString()}`);
  3310. // 获取所有活跃批次(未扫描的)
  3311. const activeLots = combinedLotData.filter(lot => {
  3312. return (
  3313. lot.lotAvailability !== 'rejected' &&
  3314. lot.stockOutLineStatus !== 'rejected' &&
  3315. lot.stockOutLineStatus !== 'completed' &&
  3316. lot.stockOutLineStatus !== 'checked' && // ✅ 只处理未扫描的
  3317. lot.processingStatus !== 'completed' &&
  3318. lot.noLot !== true &&
  3319. lot.lotNo // ✅ 必须有 lotNo
  3320. );
  3321. });
  3322. if (activeLots.length === 0) {
  3323. console.log("No active lots to scan");
  3324. return;
  3325. }
  3326. console.log(`📦 Batch scanning ${activeLots.length} active lots using batch API...`);
  3327. try {
  3328. // ✅ 转换为批量扫描 API 所需的格式
  3329. const lines: BatchScanLineRequest[] = activeLots.map((lot) => ({
  3330. pickOrderLineId: Number(lot.pickOrderLineId),
  3331. inventoryLotLineId: lot.lotId ? Number(lot.lotId) : null,
  3332. pickOrderConsoCode: String(lot.pickOrderConsoCode || ''),
  3333. lotNo: lot.lotNo || null,
  3334. itemId: Number(lot.itemId),
  3335. itemCode: String(lot.itemCode || ''),
  3336. stockOutLineId: lot.stockOutLineId ? Number(lot.stockOutLineId) : null, // ✅ 新增
  3337. }));
  3338. const request: BatchScanRequest = {
  3339. userId: currentUserId || 0,
  3340. lines: lines
  3341. };
  3342. console.log(`📤 Sending batch scan request with ${lines.length} lines`);
  3343. console.log(`📋 Request data:`, JSON.stringify(request, null, 2));
  3344. const scanStartTime = performance.now();
  3345. // ✅ 使用新的批量扫描 API(一次性处理所有请求)
  3346. const result = await batchScan(request);
  3347. const scanTime = performance.now() - scanStartTime;
  3348. console.log(` Batch scan API call completed in ${scanTime.toFixed(2)}ms (${(scanTime / 1000).toFixed(3)}s)`);
  3349. console.log(`📥 Batch scan result:`, result);
  3350. // ✅ 刷新数据以获取最新的状态
  3351. const refreshStartTime = performance.now();
  3352. await fetchAllCombinedLotData();
  3353. const refreshTime = performance.now() - refreshStartTime;
  3354. console.log(` Data refresh time: ${refreshTime.toFixed(2)}ms (${(refreshTime / 1000).toFixed(3)}s)`);
  3355. const totalTime = performance.now() - startTime;
  3356. console.log(` [BATCH SCAN END]`);
  3357. console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`);
  3358. console.log(` End time: ${new Date().toISOString()}`);
  3359. if (result && result.code === "SUCCESS") {
  3360. setQrScanSuccess(true);
  3361. setQrScanError(false);
  3362. } else {
  3363. console.error("❌ Batch scan failed:", result);
  3364. setQrScanError(true);
  3365. setQrScanSuccess(false);
  3366. }
  3367. } catch (error) {
  3368. console.error("❌ Error in batch scan:", error);
  3369. setQrScanError(true);
  3370. setQrScanSuccess(false);
  3371. }
  3372. }, [combinedLotData, fetchAllCombinedLotData, currentUserId]);
  3373. const handleSubmitAllScanned = useCallback(async () => {
  3374. const startTime = performance.now();
  3375. console.log(` [BATCH SUBMIT START]`);
  3376. console.log(` Start time: ${new Date().toISOString()}`);
  3377. const scannedLots = combinedLotData.filter(lot => {
  3378. const status = lot.stockOutLineStatus;
  3379. const statusLower = String(status || "").toLowerCase();
  3380. if (statusLower === "completed" || statusLower === "complete") {
  3381. return false;
  3382. }
  3383. // Workbench batch submit is now dedicated to closing noLot / expired / unavailable rows (qty=0 via workbench scan-pick batch).
  3384. if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) {
  3385. return true;
  3386. }
  3387. return false;
  3388. });
  3389. if (scannedLots.length === 0) {
  3390. console.log("No scanned items to submit");
  3391. return;
  3392. }
  3393. setIsSubmittingAll(true);
  3394. console.log(`📦 Submitting ${scannedLots.length} items using workbench batch scan-pick (qty=0)...`);
  3395. try {
  3396. const submitStartTime = performance.now();
  3397. const result = await workbenchBatchScanPick({
  3398. lines: scannedLots.map((lot) => ({
  3399. stockOutLineId: Number(lot.stockOutLineId) || 0,
  3400. lotNo: "", // qty=0 path allows empty lotNo (workbench zero-complete)
  3401. qty: 0,
  3402. storeId: fgPickOrders?.[0]?.storeId ?? null,
  3403. userId: currentUserId ?? 1,
  3404. })),
  3405. });
  3406. const submitTime = performance.now() - submitStartTime;
  3407. console.log(` Batch submit API call completed in ${submitTime.toFixed(2)}ms (${(submitTime / 1000).toFixed(3)}s)`);
  3408. console.log(`📥 Batch submit result:`, result);
  3409. // Refresh data once after batch submission
  3410. const refreshStartTime = performance.now();
  3411. await fetchAllCombinedLotData();
  3412. const refreshTime = performance.now() - refreshStartTime;
  3413. console.log(` Data refresh time: ${refreshTime.toFixed(2)}ms (${(refreshTime / 1000).toFixed(3)}s)`);
  3414. const totalTime = performance.now() - startTime;
  3415. console.log(` [BATCH SUBMIT END]`);
  3416. console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`);
  3417. console.log(` End time: ${new Date().toISOString()}`);
  3418. if (result && result.code === "SUCCESS") {
  3419. setQrScanSuccess(true);
  3420. setTimeout(() => {
  3421. setQrScanSuccess(false);
  3422. checkAndAutoAssignNext();
  3423. if (onSwitchToRecordTab) {
  3424. onSwitchToRecordTab();
  3425. }
  3426. if (onRefreshReleasedOrderCount) {
  3427. onRefreshReleasedOrderCount();
  3428. }
  3429. }, 2000);
  3430. } else {
  3431. console.error("Batch submit failed:", result);
  3432. setQrScanError(true);
  3433. }
  3434. } catch (error) {
  3435. console.error("Error submitting all scanned items:", error);
  3436. setQrScanError(true);
  3437. } finally {
  3438. setIsSubmittingAll(false);
  3439. }
  3440. }, [combinedLotData, fetchAllCombinedLotData, checkAndAutoAssignNext, currentUserId, onSwitchToRecordTab, onRefreshReleasedOrderCount, fgPickOrders]);
  3441. // Calculate scanned items count
  3442. // Calculate scanned items count (should match handleSubmitAllScanned filter logic)
  3443. const scannedItemsCount = useMemo(() => {
  3444. const filtered = combinedLotData.filter(lot => {
  3445. const status = lot.stockOutLineStatus;
  3446. const statusLower = String(status || "").toLowerCase();
  3447. if (statusLower === "completed" || statusLower === "complete") {
  3448. return false;
  3449. }
  3450. // Keep consistent with handleSubmitAllScanned: batch submit is only for noLot/expired/unavailable.
  3451. if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) {
  3452. return true;
  3453. }
  3454. return false;
  3455. });
  3456. // 添加调试日志
  3457. const noLotCount = filtered.filter(l => l.noLot === true).length;
  3458. const normalCount = filtered.filter(l => l.noLot !== true).length;
  3459. console.log(`📊 scannedItemsCount calculation: total=${filtered.length}, noLot=${noLotCount}, normal=${normalCount}`);
  3460. console.log(`📊 All items breakdown:`, {
  3461. total: combinedLotData.length,
  3462. noLot: combinedLotData.filter(l => l.noLot === true).length,
  3463. normal: combinedLotData.filter(l => l.noLot !== true).length
  3464. });
  3465. return filtered.length;
  3466. }, [combinedLotData]);
  3467. /*
  3468. // ADD THIS: Auto-stop scan when no data available
  3469. useEffect(() => {
  3470. if (isManualScanning && combinedLotData.length === 0) {
  3471. console.log("⏹️ No data available, auto-stopping QR scan...");
  3472. handleStopScan();
  3473. }
  3474. }, [combinedLotData.length, isManualScanning, handleStopScan]);
  3475. */
  3476. // Cleanup effect
  3477. useEffect(() => {
  3478. return () => {
  3479. // Cleanup when component unmounts (e.g., when switching tabs)
  3480. if (isManualScanning) {
  3481. console.log("🧹 Pick execution component unmounting, stopping QR scanner...");
  3482. stopScan();
  3483. resetScan();
  3484. }
  3485. };
  3486. }, [isManualScanning, stopScan, resetScan]);
  3487. const blankLabelPrintInFlightRef = useRef(false);
  3488. const [isPrintingBlankLabels, setIsPrintingBlankLabels] = useState(false);
  3489. const workbenchDoPickOrderId = fgPickOrders[0]?.doPickOrderId;
  3490. const askBlankLabelQuantity = useCallback(async (): Promise<number | null> => {
  3491. const result = await Swal.fire({
  3492. title: t("Enter blank label print quantity:"),
  3493. icon: "info",
  3494. input: "number",
  3495. inputPlaceholder: t("Label print quantity"),
  3496. inputAttributes: {
  3497. min: "1",
  3498. step: "1",
  3499. },
  3500. inputValidator: (value) => {
  3501. if (!value) return t("You need to enter a number");
  3502. if (parseInt(value, 10) < 1) return t("Number must be at least 1");
  3503. return null;
  3504. },
  3505. showCancelButton: true,
  3506. confirmButtonText: t("Confirm"),
  3507. cancelButtonText: t("Cancel"),
  3508. confirmButtonColor: "#8dba00",
  3509. cancelButtonColor: "#F04438",
  3510. });
  3511. if (!result.isConfirmed) return null;
  3512. return parseInt(result.value, 10);
  3513. }, [t]);
  3514. const handlePrintBlankPageLabels = useCallback(async () => {
  3515. if (blankLabelPrintInFlightRef.current) return;
  3516. if (!labelPrinter) {
  3517. await Swal.fire({
  3518. position: "bottom-end",
  3519. icon: "warning",
  3520. text: t("Please select a label printer first"),
  3521. showConfirmButton: false,
  3522. timer: 1500,
  3523. });
  3524. return;
  3525. }
  3526. if (!workbenchDoPickOrderId) return;
  3527. const labelPrintQty = await askBlankLabelQuantity();
  3528. if (!labelPrintQty) return;
  3529. blankLabelPrintInFlightRef.current = true;
  3530. setIsPrintingBlankLabels(true);
  3531. try {
  3532. const response = await printDNLabelsWorkbench({
  3533. deliveryOrderPickOrderId: workbenchDoPickOrderId,
  3534. printerId: labelPrinter.id,
  3535. printQty: 1,
  3536. numOfCarton: labelPrintQty,
  3537. blankCartonNumber: true,
  3538. });
  3539. if (response.success) {
  3540. await Swal.fire({
  3541. position: "bottom-end",
  3542. icon: "success",
  3543. text: t("Printed Successfully."),
  3544. showConfirmButton: false,
  3545. timer: 1500,
  3546. });
  3547. }
  3548. } finally {
  3549. setIsPrintingBlankLabels(false);
  3550. blankLabelPrintInFlightRef.current = false;
  3551. }
  3552. }, [askBlankLabelQuantity, labelPrinter, t, workbenchDoPickOrderId]);
  3553. const getStatusMessage = useCallback((lot: any) => {
  3554. switch (lot.stockOutLineStatus?.toLowerCase()) {
  3555. case 'pending':
  3556. return t("Please finish QR code scan and pick order.");
  3557. case 'partially_completed':
  3558. return t("Partial quantity submitted. Please submit more or complete the order.");
  3559. case 'completed':
  3560. return t("Pick order completed successfully!");
  3561. case 'rejected':
  3562. return t("Lot has been rejected and marked as unavailable.");
  3563. case 'unavailable':
  3564. return t("This order is insufficient, please pick another lot.");
  3565. default:
  3566. return t("Please finish QR code scan and pick order.");
  3567. }
  3568. }, [t]);
  3569. return (
  3570. <TestQrCodeProvider
  3571. lotData={combinedLotData}
  3572. onScanLot={handleQrCodeSubmit}
  3573. onBatchScan={handleBatchScan}
  3574. filterActive={(lot) => (
  3575. lot.lotAvailability !== 'rejected' &&
  3576. lot.stockOutLineStatus !== 'rejected' &&
  3577. lot.stockOutLineStatus !== 'completed'
  3578. )}
  3579. >
  3580. <FormProvider {...formProps}>
  3581. <Stack spacing={2}>
  3582. <Box
  3583. sx={{
  3584. // Keep visible while scrolling, but don't cover global top header
  3585. position: 'sticky',
  3586. top: 0,
  3587. zIndex: 5,
  3588. backgroundColor: 'background.paper',
  3589. pt: 1,
  3590. pb: 1,
  3591. px: 2,
  3592. borderBottom: '1px solid',
  3593. borderColor: 'divider',
  3594. boxShadow: 'none',
  3595. }}
  3596. >
  3597. <Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
  3598. <Box sx={{ flex: 1, minWidth: 200 }}>
  3599. <LinearProgressWithLabel
  3600. completed={progress.completed}
  3601. total={progress.total}
  3602. label={t("Progress")}
  3603. />
  3604. </Box>
  3605. <Button
  3606. variant="outlined"
  3607. onClick={() => void handlePrintBlankPageLabels()}
  3608. disabled={!workbenchDoPickOrderId || isPrintingBlankLabels}
  3609. sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
  3610. >
  3611. {isPrintingBlankLabels ? (
  3612. <>
  3613. <CircularProgress size={16} sx={{ mr: 1 }} />
  3614. {t("Printing...")}
  3615. </>
  3616. ) : (
  3617. t("Print Blank Page Labels")
  3618. )}
  3619. </Button>
  3620. </Box>
  3621. <ScanStatusAlert
  3622. error={qrScanError}
  3623. success={qrScanSuccess}
  3624. errorMessage={
  3625. qrScanErrorMsg ||
  3626. t("QR code does not match any item in current orders.")
  3627. }
  3628. successMessage={t("QR code verified.")}
  3629. />
  3630. </Box>
  3631. {/* DO Header */}
  3632. {/* 保留:Combined Lot Table - 包含所有 QR 扫描功能 */}
  3633. <Box>
  3634. <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, mt: 2 }}>
  3635. <Typography variant="h6" gutterBottom sx={{ mb: 0 }}>
  3636. {t("All Pick Order Lots")}
  3637. </Typography>
  3638. <Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
  3639. {/* Scanner status indicator (always visible) */}
  3640. {/*
  3641. <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
  3642. <QrCodeIcon
  3643. sx={{
  3644. color: isManualScanning ? '#4caf50' : '#9e9e9e',
  3645. animation: isManualScanning ? 'pulse 2s infinite' : 'none',
  3646. '@keyframes pulse': {
  3647. '0%, 100%': { opacity: 1 },
  3648. '50%': { opacity: 0.5 }
  3649. }
  3650. }}
  3651. />
  3652. <Typography variant="body2" sx={{ color: isManualScanning ? '#4caf50' : '#9e9e9e' }}>
  3653. {isManualScanning ? t("Scanner Active") : t("Scanner Inactive")}
  3654. </Typography>
  3655. </Box>
  3656. */}
  3657. {/* Pause/Resume button instead of Start/Stop */}
  3658. {isManualScanning ? (
  3659. <Button
  3660. variant="outlined"
  3661. startIcon={<QrCodeIcon />}
  3662. onClick={handleStopScan}
  3663. color={isExtraTicket ? "secondary" : "primary"}
  3664. sx={{ minWidth: '120px' }}
  3665. >
  3666. {t("Stop QR Scan")}
  3667. </Button>
  3668. ) : (
  3669. <Button
  3670. variant="contained"
  3671. startIcon={<QrCodeIcon />}
  3672. onClick={handleStartScan}
  3673. color={isExtraTicket ? "secondary" : "primary"}
  3674. sx={{ minWidth: '120px' }}
  3675. >
  3676. {t("Start QR Scan")}
  3677. </Button>
  3678. )}
  3679. {/* 保留:Submit All Scanned Button */}
  3680. <Button
  3681. variant="contained"
  3682. color={isExtraTicket ? "secondary" : "success"}
  3683. onClick={handleSubmitAllScanned}
  3684. disabled={
  3685. scannedItemsCount === 0
  3686. || isSubmittingAll}
  3687. sx={{ minWidth: '160px' }}
  3688. >
  3689. {isSubmittingAll ? (
  3690. <>
  3691. <CircularProgress size={16} sx={{ mr: 1, color: 'white' }} />
  3692. {t("Submitting...")}
  3693. </>
  3694. ) : (
  3695. `${t("Submit All Scanned")} (${scannedItemsCount})`
  3696. )}
  3697. </Button>
  3698. </Box>
  3699. </Box>
  3700. {fgPickOrders.length > 0 && (
  3701. <Paper
  3702. sx={{
  3703. p: 2,
  3704. mb: 2,
  3705. borderLeft: isExtraTicket ? "6px solid" : "none",
  3706. borderColor: isExtraTicket ? "secondary.main" : "transparent",
  3707. backgroundColor: isExtraTicket ? "#f8f0ff" : "background.paper",
  3708. }}
  3709. >
  3710. <Stack spacing={2}>
  3711. {isExtraTicket && (
  3712. <Alert severity="info" sx={{ mb: 0.5, borderColor: "secondary.main", color: "secondary.dark" }}>
  3713. {t("Etra Ticket Notice")}
  3714. </Alert>
  3715. )}
  3716. {/* 基本信息 */}
  3717. <Stack direction="row" spacing={4} useFlexGap flexWrap="wrap">
  3718. {isExtraTicket && (
  3719. <Chip
  3720. label={t("isExtra order")}
  3721. color="secondary"
  3722. variant="filled"
  3723. sx={{ fontWeight: 700 }}
  3724. />
  3725. )}
  3726. <Typography variant="subtitle1">
  3727. <strong>{t("Shop Name")}:</strong> {fgPickOrders[0].shopName || '-'}
  3728. </Typography>
  3729. <Typography variant="subtitle1">
  3730. <strong>{t("Store ID")}:</strong> {fgPickOrders[0].storeId || '-'}
  3731. </Typography>
  3732. <Typography variant="subtitle1">
  3733. <strong>{t("Ticket No.")}:</strong> {fgPickOrders[0].ticketNo || '-'}{isExtraTicket ? ` (${t("isExtra order")})` : ""}
  3734. </Typography>
  3735. <Typography variant="subtitle1">
  3736. <strong>{t("Departure Time")}:</strong> {fgPickOrders[0].DepartureTime || '-'}
  3737. </Typography>
  3738. </Stack>
  3739. {/* 改进:三个字段显示在一起,使用表格式布局 */}
  3740. {/* 改进:三个字段合并显示 */}
  3741. {/* 改进:表格式显示每个 pick order */}
  3742. <Box sx={{
  3743. p: 2,
  3744. backgroundColor: '#f5f5f5',
  3745. borderRadius: 1
  3746. }}>
  3747. <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 'bold' }}>
  3748. {t("Pick Orders Details")}:
  3749. </Typography>
  3750. {(() => {
  3751. const pickOrderCodes = fgPickOrders[0].pickOrderCodes as string[] | string | undefined;
  3752. const deliveryNos = fgPickOrders[0].deliveryNos as string[] | string | undefined;
  3753. const lineCounts = fgPickOrders[0].lineCountsPerPickOrder;
  3754. const pickOrderCodesArray = Array.isArray(pickOrderCodes)
  3755. ? pickOrderCodes
  3756. : (typeof pickOrderCodes === 'string' ? pickOrderCodes.split(', ') : []);
  3757. const deliveryNosArray = Array.isArray(deliveryNos)
  3758. ? deliveryNos
  3759. : (typeof deliveryNos === 'string' ? deliveryNos.split(', ') : []);
  3760. const lineCountsArray = Array.isArray(lineCounts) ? lineCounts : [];
  3761. const maxLength = Math.max(
  3762. pickOrderCodesArray.length,
  3763. deliveryNosArray.length,
  3764. lineCountsArray.length
  3765. );
  3766. if (maxLength === 0) {
  3767. return <Typography variant="body2" color="text.secondary">-</Typography>;
  3768. }
  3769. // 使用与外部基本信息相同的样式
  3770. return Array.from({ length: maxLength }, (_, idx) => (
  3771. <Stack
  3772. key={idx}
  3773. direction="row"
  3774. spacing={4}
  3775. useFlexGap
  3776. flexWrap="wrap"
  3777. sx={{ mb: idx < maxLength - 1 ? 1 : 0 }} // 除了最后一行,都添加底部间距
  3778. >
  3779. <Typography variant="subtitle1">
  3780. <strong>{t("Delivery Order")}:</strong> {deliveryNosArray[idx] || '-'}
  3781. </Typography>
  3782. <Typography variant="subtitle1">
  3783. <strong>{t("Pick Order")}:</strong> {pickOrderCodesArray[idx] || '-'}
  3784. </Typography>
  3785. <Typography variant="subtitle1">
  3786. <strong>{t("Finsihed good items")}:</strong> {lineCountsArray[idx] || '-'}<strong>{t("kinds")}</strong>
  3787. </Typography>
  3788. </Stack>
  3789. ));
  3790. })()}
  3791. </Box>
  3792. </Stack>
  3793. </Paper>
  3794. )}
  3795. <TableContainer component={Paper}>
  3796. <Table>
  3797. <TableHead>
  3798. <TableRow>
  3799. <TableCell>{t("Index")}</TableCell>
  3800. <TableCell>{t("Item Code")}</TableCell>
  3801. <TableCell>{t("Item Name")}</TableCell>
  3802. <TableCell
  3803. align="center"
  3804. sx={{ minWidth: 220, whiteSpace: "nowrap" }}
  3805. >
  3806. {`${t("Route")} / ${t("Suggest Lot No.")}`}
  3807. </TableCell>
  3808. <TableCell align="right" sx={{ whiteSpace: "nowrap", width: 88, px: 1 }}>
  3809. {t("Lot Required Pick Qty")}
  3810. </TableCell>
  3811. <TableCell
  3812. align="center"
  3813. sx={{ width: 88, minWidth: 88, px: 0.5, whiteSpace: "nowrap" }}
  3814. >
  3815. {t("Scan Result")}
  3816. </TableCell>
  3817. {/*<TableCell align="center">{t("Qty will submit")}</TableCell>*/}
  3818. <TableCell align="center">{t("Submit Required Pick Qty")}</TableCell>
  3819. </TableRow>
  3820. </TableHead>
  3821. <TableBody>
  3822. {paginatedData.length === 0 ? (
  3823. <TableRow>
  3824. <TableCell colSpan={7} align="center">
  3825. <Typography variant="body2" color="text.secondary">
  3826. {t("No data available")}
  3827. </Typography>
  3828. </TableCell>
  3829. </TableRow>
  3830. ) : (
  3831. // 在第 1797-1938 行之间,将整个 map 函数修改为:
  3832. paginatedData.map((row, index) => {
  3833. const lot = row.lot;
  3834. const solIdForKey = Number(lot.stockOutLineId) || 0;
  3835. const lotKeyForSubmitQty =
  3836. Number.isFinite(solIdForKey) && solIdForKey > 0 ? `sol:${solIdForKey}` : `${lot.pickOrderLineId}-${lot.lotId}`;
  3837. const lockedSubmitQtyDisplay = isInventoryLotLineUnavailable(lot) ? 0 : resolveSingleSubmitQty(lot);
  3838. const hasPickOverride = Object.prototype.hasOwnProperty.call(pickQtyData, lotKeyForSubmitQty);
  3839. const fromPickRow = hasPickOverride ? pickQtyData[lotKeyForSubmitQty] : undefined;
  3840. const workbenchSubmitQtyDisplay =
  3841. hasPickOverride && fromPickRow !== undefined && fromPickRow !== null && !Number.isNaN(Number(fromPickRow))
  3842. ? Number(fromPickRow)
  3843. : lockedSubmitQtyDisplay;
  3844. const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo;
  3845. const rejectDisplay = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t);
  3846. const solSt = String(lot.stockOutLineStatus || "").toLowerCase();
  3847. const isSolRejected =
  3848. solSt === "rejected" || String(lot.lotAvailability || "").toLowerCase() === "rejected";
  3849. const groupRowBg =
  3850. row.groupDisplayIndex % 2 === 0 ? "action.hover" : "background.paper";
  3851. const nextRow = paginatedData[index + 1];
  3852. const isLastInGroup =
  3853. !nextRow || nextRow.groupDisplayIndex !== row.groupDisplayIndex;
  3854. return (
  3855. <TableRow
  3856. key={`${lot.pickOrderLineId}-${lot.lotId || 'null'}`}
  3857. sx={{
  3858. backgroundColor: groupRowBg,
  3859. "&:nth-of-type(even)": { backgroundColor: groupRowBg },
  3860. "& .MuiTableCell-root": {
  3861. backgroundColor: groupRowBg,
  3862. ...(!isLastInGroup ? { borderBottom: "none" } : {}),
  3863. },
  3864. }}
  3865. >
  3866. <TableCell>
  3867. <Typography variant="body1" fontWeight="bold">
  3868. {row.isGroupFirst ? row.groupDisplayIndex : ""}
  3869. </Typography>
  3870. </TableCell>
  3871. <TableCell>
  3872. <Typography variant="body1">
  3873. {row.isGroupFirst ? lot.itemCode : ""}
  3874. </Typography>
  3875. </TableCell>
  3876. <TableCell>
  3877. <Typography variant="body1">
  3878. {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""}
  3879. </Typography>
  3880. </TableCell>
  3881. <TableCell align="center" sx={{ minWidth: 220 }}>
  3882. <Stack
  3883. direction="row"
  3884. spacing={1}
  3885. alignItems="center"
  3886. justifyContent="center"
  3887. sx={{ width: "100%" }}
  3888. >
  3889. <Box sx={{ textAlign: "center" }}>
  3890. <Typography variant="body2" sx={{ whiteSpace: "nowrap", textAlign: "center" }}>
  3891. {lot.routerRoute || "-"}
  3892. </Typography>
  3893. {(() => {
  3894. const hasLotNo = Boolean(lot.lotNo);
  3895. const isExpired = lot.lotAvailability === 'expired';
  3896. const isUnavailable = isInventoryLotLineUnavailable(lot);
  3897. const isNoLotHint = !hasLotNo && !rejectDisplay; // 顯示「請檢查周圍…」那種
  3898. const solStatus = String(lot.stockOutLineStatus ?? "").toLowerCase();
  3899. const isComplete =
  3900. solStatus === "completed" ||
  3901. solStatus === "checked" ||
  3902. solStatus === "partially_completed" ||
  3903. solStatus === "partially_complete";
  3904. const textColor =
  3905. isNoLotHint
  3906. ? "error.main"
  3907. : rejectDisplay || isSolRejected || isUnavailable
  3908. ? "error.main"
  3909. : isExpired
  3910. ? "warning.main"
  3911. : isComplete
  3912. ? "success.main"
  3913. : "inherit";
  3914. const lotOnly =
  3915. hasLotNo && !rejectDisplay && !isExpired && !isUnavailable;
  3916. return (
  3917. <Typography
  3918. variant="body2"
  3919. sx={{
  3920. color: textColor,
  3921. whiteSpace: lotOnly ? "nowrap" : "pre-wrap",
  3922. wordBreak: lotOnly ? "normal" : "break-word",
  3923. textAlign: "center",
  3924. }}
  3925. >
  3926. {hasLotNo ? (
  3927. rejectDisplay ? (
  3928. <>
  3929. {lot.lotNo}
  3930. <Box component="span" sx={{ display: 'block', mt: 0.5, color: 'error.main' }}>
  3931. {rejectDisplay}
  3932. </Box>
  3933. </>
  3934. ) : isExpired ? (
  3935. <>
  3936. {lot.lotNo}{' '}
  3937. {t('is expired. Please check around have available QR code or not.')}
  3938. </>
  3939. ) : isUnavailable ? (
  3940. <>
  3941. {lot.lotNo}{' '}
  3942. {t('is unavailable. Please check around have available QR code or not.')}
  3943. </>
  3944. ) : (
  3945. lot.lotNo
  3946. )
  3947. ) : (
  3948. rejectDisplay ||
  3949. t(
  3950. "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.",
  3951. )
  3952. )}
  3953. </Typography>
  3954. );
  3955. })()}
  3956. </Box>
  3957. {/* Lot QR Code Button */}
  3958. {Number(lot.stockOutLineId) > 0 && Number(lot.itemId) > 0 && (
  3959. <Button
  3960. variant="outlined"
  3961. size="small"
  3962. onClick={() => openWorkbenchLotLabelModalForLot(lot)}
  3963. sx={{
  3964. flexShrink: 0,
  3965. fontSize: "0.75rem",
  3966. minWidth: "auto",
  3967. px: 1,
  3968. whiteSpace: "nowrap",
  3969. }}
  3970. >
  3971. {t("lot QR code")}
  3972. </Button>
  3973. )}
  3974. </Stack>
  3975. </TableCell>
  3976. <TableCell align="right" sx={{ whiteSpace: "nowrap", width: 88, px: 1 }}>
  3977. <Typography variant="body1">
  3978. {(() => {
  3979. const requiredQty = lot.requiredQty || 0;
  3980. return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')';
  3981. })()}
  3982. </Typography>
  3983. </TableCell>
  3984. <TableCell align="center" sx={{ width: 88, minWidth: 88, px: 0.5 }}>
  3985. {(() => {
  3986. const status = lot.stockOutLineStatus?.toLowerCase();
  3987. const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected';
  3988. const isNoLot = !lot.lotNo;
  3989. const scanResultSlotSx = {
  3990. width: 42,
  3991. height: 42,
  3992. display: 'flex',
  3993. justifyContent: 'center',
  3994. alignItems: 'center',
  3995. mx: 'auto',
  3996. } as const;
  3997. // rejected lot:显示红色勾选(已扫描但被拒绝)
  3998. if (isRejected && !isNoLot) {
  3999. return (
  4000. <Box sx={scanResultSlotSx}>
  4001. <Checkbox
  4002. checked={true}
  4003. disabled={true}
  4004. readOnly={true}
  4005. size="large"
  4006. sx={{
  4007. color: 'error.main',
  4008. '&.Mui-checked': { color: 'error.main' },
  4009. transform: 'scale(1.3)',
  4010. }}
  4011. />
  4012. </Box>
  4013. );
  4014. }
  4015. // 過期批號:與 noLot 同類——視為已掃到/可處理(含 pending),顯示警示色勾選
  4016. if (
  4017. isLotAvailabilityExpired(lot) &&
  4018. status !== "rejected" &&
  4019. status !== "completed" &&
  4020. status !== "partially_completed" &&
  4021. status !== "partially_complete"
  4022. ) {
  4023. return <Box sx={scanResultSlotSx} />;
  4024. }
  4025. // 正常 lot:已扫描(checked/partially_completed/completed)
  4026. if (!isNoLot && status !== 'pending' && status !== 'rejected') {
  4027. return (
  4028. <Box sx={scanResultSlotSx}>
  4029. <Checkbox
  4030. checked={true}
  4031. disabled={true}
  4032. readOnly={true}
  4033. size="large"
  4034. sx={{
  4035. color: 'success.main',
  4036. '&.Mui-checked': { color: 'success.main' },
  4037. transform: 'scale(1.3)',
  4038. }}
  4039. />
  4040. </Box>
  4041. );
  4042. }
  4043. // noLot 且已完成/部分完成:显示红色勾选
  4044. if (isNoLot && (status === 'partially_completed' || status === 'completed')) {
  4045. return (
  4046. <Box sx={scanResultSlotSx}>
  4047. <Checkbox
  4048. checked={true}
  4049. disabled={true}
  4050. readOnly={true}
  4051. size="large"
  4052. sx={{
  4053. color: 'error.main',
  4054. '&.Mui-checked': { color: 'error.main' },
  4055. transform: 'scale(1.3)',
  4056. }}
  4057. />
  4058. </Box>
  4059. );
  4060. }
  4061. return <Box sx={scanResultSlotSx} />;
  4062. })()}
  4063. </TableCell>
  4064. {/*
  4065. <TableCell align="center">
  4066. {workbenchSubmitQtyDisplay}
  4067. </TableCell>
  4068. */}
  4069. <TableCell align="center">
  4070. <Box sx={{ display: 'flex', justifyContent: 'center' }}>
  4071. {(() => {
  4072. const status = lot.stockOutLineStatus?.toLowerCase();
  4073. const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected';
  4074. const isNoLot = !lot.lotNo;
  4075. const isUnavailableRow = isInventoryLotLineUnavailable(lot);
  4076. // ✅ rejected lot:显示提示文本(换行显示)
  4077. if (isRejected && !isNoLot) {
  4078. const rejectHint = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t);
  4079. return (
  4080. <Typography
  4081. variant="body2"
  4082. color="error.main"
  4083. sx={{
  4084. textAlign: 'center',
  4085. whiteSpace: 'normal',
  4086. wordBreak: 'break-word',
  4087. maxWidth: '200px',
  4088. lineHeight: 1.5
  4089. }}
  4090. >
  4091. {rejectHint || t("This lot is rejected, please scan another lot.")}
  4092. </Typography>
  4093. );
  4094. }
  4095. // noLot 且非 unavailable:保留舊行為(Issue)
  4096. if (isNoLot && !isUnavailableRow) {
  4097. return (
  4098. <Button
  4099. variant="outlined"
  4100. size="small"
  4101. onClick={() => handlelotnull(lot)}
  4102. /*
  4103. disabled={
  4104. status === 'completed' ||
  4105. (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
  4106. }
  4107. */
  4108. disabled={true}
  4109. sx={{
  4110. fontSize: '0.7rem',
  4111. py: 0.5,
  4112. minHeight: '28px',
  4113. minWidth: '60px',
  4114. borderColor: 'warning.main',
  4115. color: 'warning.main'
  4116. }}
  4117. >
  4118. {t("Issue")}
  4119. </Button>
  4120. );
  4121. }
  4122. // 正常 lot:Submit + 可編輯數量(Edit 解鎖輸入,不再開 issue form)
  4123. {
  4124. const lotKey = lotKeyForSubmitQty;
  4125. const qtyFieldEnabled = workbenchSubmitQtyFieldEnabledByLotKey[lotKey] === true;
  4126. const displayedSubmitQty = workbenchSubmitQtyDisplay;
  4127. const hasPickOverrideRow = Object.prototype.hasOwnProperty.call(pickQtyData, lotKey);
  4128. const textFieldValue = qtyFieldEnabled
  4129. ? hasPickOverrideRow
  4130. ? String(pickQtyData[lotKey])
  4131. : String(displayedSubmitQty)
  4132. : String(displayedSubmitQty);
  4133. const pickStatus = String(lot.stockOutLineStatus ?? "").toLowerCase();
  4134. const isRowPicked =
  4135. pickStatus === "completed" ||
  4136. pickStatus === "checked" ||
  4137. pickStatus === "partially_completed" ||
  4138. pickStatus === "partially_complete";
  4139. return (
  4140. <Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
  4141. {isRowPicked ? (
  4142. <Typography
  4143. variant="body1"
  4144. sx={{
  4145. width: 96,
  4146. textAlign: "center",
  4147. fontWeight: 500,
  4148. }}
  4149. >
  4150. {textFieldValue}
  4151. </Typography>
  4152. ) : (
  4153. <TextField
  4154. type="number"
  4155. size="small"
  4156. disabled={!qtyFieldEnabled}
  4157. value={textFieldValue}
  4158. onKeyDown={(e) => {
  4159. if (!qtyFieldEnabled) return;
  4160. if (e.key !== "{") return;
  4161. e.preventDefault();
  4162. setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({
  4163. ...prev,
  4164. [lotKey]: false,
  4165. }));
  4166. (e.currentTarget as HTMLInputElement).blur();
  4167. }}
  4168. onChange={(e) => {
  4169. if (!qtyFieldEnabled) return;
  4170. const n = Number(e.target.value);
  4171. if (Number.isFinite(n) && n < 0) return;
  4172. handlePickQtyChange(lotKey, e.target.value);
  4173. }}
  4174. inputProps={{ min: 0, step: 1 }}
  4175. sx={{
  4176. width: 96,
  4177. "& .MuiInputBase-input": {
  4178. typography: "body1",
  4179. py: 0.5,
  4180. textAlign: "center",
  4181. "&::-webkit-outer-spin-button, &::-webkit-inner-spin-button": {
  4182. WebkitAppearance: "none",
  4183. margin: 0,
  4184. },
  4185. MozAppearance: "textfield",
  4186. },
  4187. }}
  4188. />
  4189. )}
  4190. <Button
  4191. variant="outlined"
  4192. size="small"
  4193. onClick={() => {
  4194. setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({
  4195. ...prev,
  4196. [lotKey]: !(prev[lotKey] === true),
  4197. }));
  4198. }}
  4199. disabled={
  4200. lot.stockOutLineStatus === 'completed' ||
  4201. (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
  4202. }
  4203. sx={{
  4204. fontSize: '0.7rem',
  4205. py: 0.5,
  4206. minHeight: '28px',
  4207. minWidth: '60px',
  4208. borderColor: 'warning.main',
  4209. color: 'warning.main',
  4210. }}
  4211. title={qtyFieldEnabled ? t('Lock quantity') : t('Edit quantity')}
  4212. >
  4213. {t("Edit")}
  4214. </Button>
  4215. <Button
  4216. variant="outlined"
  4217. size="small"
  4218. onClick={() => handleSkip(lot)}
  4219. disabled={
  4220. lot.stockOutLineStatus === 'completed' ||
  4221. lot.stockOutLineStatus === 'checked' ||
  4222. lot.stockOutLineStatus === 'partially_completed' ||
  4223. // 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交)
  4224. (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) ||
  4225. (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
  4226. }
  4227. sx={{
  4228. fontSize: "0.7rem",
  4229. py: 0.5,
  4230. minHeight: "28px",
  4231. minWidth: "72px",
  4232. whiteSpace: "nowrap",
  4233. flexShrink: 0,
  4234. }}
  4235. >
  4236. {t("Just Completed")}
  4237. </Button>
  4238. </Stack>
  4239. );
  4240. }
  4241. })()}
  4242. </Box>
  4243. </TableCell>
  4244. </TableRow>
  4245. );
  4246. })
  4247. )}
  4248. </TableBody>
  4249. </Table>
  4250. </TableContainer>
  4251. <TablePagination
  4252. component="div"
  4253. count={combinedLotData.length}
  4254. page={paginationController.pageNum}
  4255. rowsPerPage={paginationController.pageSize}
  4256. onPageChange={handlePageChange}
  4257. onRowsPerPageChange={handlePageSizeChange}
  4258. rowsPerPageOptions={[10, 25, 50,-1]}
  4259. labelRowsPerPage={t("Rows per page")}
  4260. labelDisplayedRows={({ from, to, count }) =>
  4261. `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
  4262. }
  4263. />
  4264. </Box>
  4265. </Stack>
  4266. {/* QR Code Scanner works in background - no modal needed */}
  4267. <ManualLotConfirmationModal
  4268. open={manualLotConfirmationOpen}
  4269. onClose={() => {
  4270. setManualLotConfirmationOpen(false);
  4271. }}
  4272. onConfirm={handleManualLotConfirmation}
  4273. expectedLot={null}
  4274. scannedLot={null}
  4275. isLoading={false}
  4276. />
  4277. <WorkbenchLotLabelPrintModal
  4278. open={workbenchLotLabelModalOpen}
  4279. onClose={() => {
  4280. setWorkbenchLotLabelModalOpen(false);
  4281. setWorkbenchLotLabelReminderText(null);
  4282. setWorkbenchLotLabelContextLot(null);
  4283. setWorkbenchLotLabelInitialPayload(null);
  4284. }}
  4285. initialPayload={workbenchLotLabelInitialPayload}
  4286. initialItemId={
  4287. workbenchLotLabelContextLot != null
  4288. ? Number(workbenchLotLabelContextLot.itemId)
  4289. : null
  4290. }
  4291. defaultPrinterName={defaultLabelPrinterName}
  4292. hideScanSection={
  4293. workbenchLotLabelInitialPayload != null ||
  4294. workbenchLotLabelContextLot != null
  4295. }
  4296. reminderText={workbenchLotLabelReminderText ?? undefined}
  4297. statusTitleText={workbenchLotLabelStatusBanner.text}
  4298. statusTitleSeverity={workbenchLotLabelStatusBanner.severity}
  4299. warehouseCodePrefixFilter={lotFloorPrefixFilter}
  4300. pickRuleScope="do"
  4301. triggerLotAvailableQty={
  4302. workbenchLotLabelContextLot != null
  4303. ? Number(workbenchLotLabelContextLot.availableQty)
  4304. : null
  4305. }
  4306. triggerLotUom={
  4307. workbenchLotLabelContextLot != null
  4308. ? String(
  4309. workbenchLotLabelContextLot.uomShortDesc ??
  4310. workbenchLotLabelContextLot.stockUnit ??
  4311. "",
  4312. ).trim() || null
  4313. : null
  4314. }
  4315. expectedUomId={
  4316. workbenchLotLabelContextLot != null &&
  4317. Number(workbenchLotLabelContextLot.uomId) > 0
  4318. ? Number(workbenchLotLabelContextLot.uomId)
  4319. : null
  4320. }
  4321. disableScanPick={workbenchLotLabelScanPickDisabled}
  4322. onWorkbenchScanPick={handleWorkbenchLotLabelScanPick}
  4323. submitQty={workbenchLotLabelSubmitQty}
  4324. onSubmitQtyChange={handleWorkbenchLotLabelSubmitQtyChange}
  4325. />
  4326. </FormProvider>
  4327. </TestQrCodeProvider>
  4328. );
  4329. };
  4330. export default WorkbenchGoodPickExecutionDetail;