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.
 
 

297 line
9.1 KiB

  1. "use client";
  2. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  3. import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
  4. export interface JobOrderListItem {
  5. id: number;
  6. code: string | null;
  7. planStart: string | null;
  8. itemCode: string | null;
  9. itemName: string | null;
  10. reqQty: number | null;
  11. stockInLineId: number | null;
  12. itemId: number | null;
  13. lotNo: string | null;
  14. /** Effective shelf life days used for print (chilled or -18, according to useMinus18). */
  15. defaultShelfLifeDays?: number | null;
  16. /** True when expiry is computed from -18 warehouse days. */
  17. useMinus18?: boolean | null;
  18. /** Print date + effective shelf life days (yyyy-MM-dd). */
  19. expiryDate?: string | null;
  20. /** 打袋機 DataFlex cumulative printed qty */
  21. bagPrintedQty?: number;
  22. /** 標簽機 cumulative printed qty */
  23. labelPrintedQty?: number;
  24. /** 激光機 cumulative printed qty */
  25. laserPrintedQty?: number;
  26. }
  27. export interface PrinterStatusRequest {
  28. printerType: "dataflex" | "laser";
  29. printerIp?: string;
  30. printerPort?: number;
  31. }
  32. export interface PrinterStatusResponse {
  33. connected: boolean;
  34. message: string;
  35. }
  36. export interface OnPackQrDownloadRequest {
  37. jobOrders: {
  38. jobOrderId: number;
  39. itemCode: string;
  40. }[];
  41. /** /bagPrint filter date (YYYY-MM-DD). Used by expiry ZIP for production date. */
  42. planDate?: string;
  43. }
  44. /** Same mapping as Bag Print download buttons: one entry per row with a non-empty item code. */
  45. export function buildOnPackJobOrdersPayload(jobOrders: JobOrderListItem[]): {
  46. jobOrderId: number;
  47. itemCode: string;
  48. }[] {
  49. return jobOrders
  50. .map((jobOrder) => ({
  51. jobOrderId: jobOrder.id,
  52. itemCode: jobOrder.itemCode?.trim() || "",
  53. }))
  54. .filter((jobOrder) => jobOrder.itemCode.length > 0);
  55. }
  56. export interface NgpclPushResponse {
  57. pushed: boolean;
  58. message: string;
  59. }
  60. /**
  61. * POST the same lemon OnPack ZIP bytes as download-onpack-qr-text to the server-configured NGPCL HTTP endpoint (ngpcl.push-url).
  62. * When the URL is not configured, response has pushed=false — use download ZIP instead.
  63. */
  64. export async function pushOnPackTextQrZipToNgpcl(request: OnPackQrDownloadRequest): Promise<NgpclPushResponse> {
  65. const url = `${NEXT_PUBLIC_API_URL}/plastic/ngpcl/push-onpack-qr-text`;
  66. const res = await clientAuthFetch(url, {
  67. method: "POST",
  68. headers: { "Content-Type": "application/json" },
  69. body: JSON.stringify(request),
  70. });
  71. if (res.status === 401 || res.status === 403) {
  72. return { pushed: false, message: "Session expired or unauthorized." };
  73. }
  74. const data = (await res.json()) as NgpclPushResponse;
  75. if (!res.ok) {
  76. throw new Error(data.message || `HTTP ${res.status}`);
  77. }
  78. return data;
  79. }
  80. /** Readable message when ZIP download returns non-OK (plain text, JSON error body, or generic). */
  81. async function zipDownloadError(res: Response): Promise<Error> {
  82. return parseBagPrintApiError(res, "下載");
  83. }
  84. /** Backend ErrorRes is `{ timestamp, traceId }` with no message; avoid calling that a ZIP download failure. */
  85. async function parseBagPrintApiError(res: Response, action: string): Promise<Error> {
  86. const text = await res.text();
  87. const ct = res.headers.get("content-type") ?? "";
  88. if (ct.includes("application/json")) {
  89. try {
  90. const j = JSON.parse(text) as { message?: string; error?: string; traceId?: string };
  91. if (typeof j.message === "string" && j.message.length > 0) {
  92. return new Error(j.message);
  93. }
  94. if (typeof j.error === "string" && j.error.length > 0) {
  95. return new Error(j.error);
  96. }
  97. if (typeof j.traceId === "string" && j.traceId.length > 0) {
  98. return new Error(
  99. `${action}失敗(HTTP ${res.status})。請重啟後端以執行 Liquibase,或查看日誌 traceId ${j.traceId}。`,
  100. );
  101. }
  102. } catch {
  103. /* ignore parse */
  104. }
  105. }
  106. if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) {
  107. return new Error(text);
  108. }
  109. return new Error(`${action}失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`);
  110. }
  111. /**
  112. * Fetch job orders by plan date from GET /py/job-orders.
  113. * Client-side only; uses auth token from localStorage.
  114. */
  115. export async function fetchJobOrders(planStart: string): Promise<JobOrderListItem[]> {
  116. const url = `${NEXT_PUBLIC_API_URL}/py/job-orders?planStart=${encodeURIComponent(planStart)}`;
  117. const res = await clientAuthFetch(url, { method: "GET" });
  118. if (!res.ok) {
  119. throw new Error(`Failed to fetch job orders: ${res.status}`);
  120. }
  121. return res.json();
  122. }
  123. export async function checkPrinterStatus(
  124. request: PrinterStatusRequest,
  125. ): Promise<PrinterStatusResponse> {
  126. const url = `${NEXT_PUBLIC_API_URL}/plastic/check-printer`;
  127. const res = await clientAuthFetch(url, {
  128. method: "POST",
  129. headers: { "Content-Type": "application/json" },
  130. body: JSON.stringify(request),
  131. });
  132. const data = (await res.json()) as PrinterStatusResponse;
  133. if (!res.ok) {
  134. return data;
  135. }
  136. return data;
  137. }
  138. export async function downloadOnPackQrZip(
  139. request: OnPackQrDownloadRequest,
  140. ): Promise<Blob> {
  141. const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr`;
  142. const res = await clientAuthFetch(url, {
  143. method: "POST",
  144. headers: { "Content-Type": "application/json" },
  145. body: JSON.stringify(request),
  146. });
  147. if (!res.ok) {
  148. throw await zipDownloadError(res);
  149. }
  150. return res.blob();
  151. }
  152. /** 汁水機 OnPack — same as QR ZIP, plus LOGO_EXP BMP from item_default_shelf_life. */
  153. export async function downloadOnPackQrZipWithExpiry(
  154. request: OnPackQrDownloadRequest,
  155. ): Promise<Blob> {
  156. const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-with-expiry`;
  157. const res = await clientAuthFetch(url, {
  158. method: "POST",
  159. headers: { "Content-Type": "application/json" },
  160. body: JSON.stringify(request),
  161. });
  162. if (!res.ok) {
  163. throw await zipDownloadError(res);
  164. }
  165. return res.blob();
  166. }
  167. /** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */
  168. export async function downloadOnPackTextQrZip(
  169. request: OnPackQrDownloadRequest,
  170. ): Promise<Blob> {
  171. const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text`;
  172. const res = await clientAuthFetch(url, {
  173. method: "POST",
  174. headers: { "Content-Type": "application/json" },
  175. body: JSON.stringify(request),
  176. });
  177. if (!res.ok) {
  178. throw await zipDownloadError(res);
  179. }
  180. return res.blob();
  181. }
  182. /** OnPack2023 檸檬機 — same as text ZIP, plus TEXT_EXP from item_default_shelf_life. */
  183. export async function downloadOnPackTextQrZipWithExpiry(
  184. request: OnPackQrDownloadRequest,
  185. ): Promise<Blob> {
  186. const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text-with-expiry`;
  187. const res = await clientAuthFetch(url, {
  188. method: "POST",
  189. headers: { "Content-Type": "application/json" },
  190. body: JSON.stringify(request),
  191. });
  192. if (!res.ok) {
  193. throw await zipDownloadError(res);
  194. }
  195. return res.blob();
  196. }
  197. export type OnPackMachine = "juice" | "lemon";
  198. export interface OnPackTemplateFileDto {
  199. id: number;
  200. machine: OnPackMachine | string;
  201. itemCode: string;
  202. fileName: string;
  203. byteSize: number;
  204. modified?: string | null;
  205. }
  206. export interface OnPackTemplateUploadResponse {
  207. machine: string;
  208. itemCode: string;
  209. saved: string[];
  210. }
  211. export interface OnPackSupportedItemDto {
  212. itemCode: string;
  213. printable: boolean;
  214. inDatabase: boolean;
  215. builtin: boolean;
  216. registered: boolean;
  217. }
  218. export interface OnPackSupportedCatalogDto {
  219. juice: OnPackSupportedItemDto[];
  220. lemon: OnPackSupportedItemDto[];
  221. }
  222. export async function fetchOnPackSupportedCatalog(): Promise<OnPackSupportedCatalogDto> {
  223. const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/supported`;
  224. const res = await clientAuthFetch(url, { method: "GET" });
  225. if (!res.ok) {
  226. throw await parseBagPrintApiError(res, "讀取 OnPack 支援清單");
  227. }
  228. return (await res.json()) as OnPackSupportedCatalogDto;
  229. }
  230. export async function listOnPackTemplates(machine?: OnPackMachine): Promise<OnPackTemplateFileDto[]> {
  231. const q = machine ? `?machine=${encodeURIComponent(machine)}` : "";
  232. const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates${q}`;
  233. const res = await clientAuthFetch(url, { method: "GET" });
  234. if (!res.ok) {
  235. throw await parseBagPrintApiError(res, "讀取 OnPack 模板");
  236. }
  237. return (await res.json()) as OnPackTemplateFileDto[];
  238. }
  239. export async function uploadOnPackTemplates(
  240. machine: OnPackMachine,
  241. itemCode: string,
  242. files: File[],
  243. ): Promise<OnPackTemplateUploadResponse> {
  244. const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates`;
  245. const body = new FormData();
  246. body.append("machine", machine);
  247. body.append("itemCode", itemCode);
  248. files.forEach((f) => body.append("files", f));
  249. const res = await clientAuthFetch(url, { method: "POST", body });
  250. if (!res.ok) {
  251. throw await parseBagPrintApiError(res, "上傳 OnPack 模板");
  252. }
  253. return (await res.json()) as OnPackTemplateUploadResponse;
  254. }
  255. export async function deleteOnPackTemplate(id: number): Promise<void> {
  256. const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/${id}`;
  257. const res = await clientAuthFetch(url, { method: "DELETE" });
  258. if (!res.ok && res.status !== 204) {
  259. throw await parseBagPrintApiError(res, "刪除 OnPack 模板");
  260. }
  261. }