File size: 6,928 Bytes
122c1d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
import {
  AllObjectsResponse,
  BatchResponse,
  ClientProfile,
  ColumnMappingSuggestion,
  ExportFormat,
  ExportFormatResponse,
  LineCheckRequest,
  LineCheckResponse,
  MakeRuleRequest,
  ReferenceDataStatus,
  ReviewQuery,
  ReviewRow,
  ReviewTableResponse,
  RowReviewRequest,
  RuleResponse,
  UploadOptions,
} from './types';

export const API_BASE_URL = (
  process.env.REACT_APP_API_URL ?? 'http://127.0.0.1:8000'
).replace(/\/$/, '');

/** The envelope every endpoint returns — `cbh/core/wrappers.py`. */
interface CbhResponseWrapper<T> {
  data: T | null;
  successful: boolean;
  error: { message: string } | null;
}

export class ApiError extends Error {
  readonly status: number;

  constructor(message: string, status: number) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
  }
}

const unreachable = () =>
  new ApiError(
    `Cannot reach the check service at ${API_BASE_URL}. Is the backend running?`,
    0
  );

async function send(path: string, init?: RequestInit): Promise<Response> {
  // A FormData body must set its own multipart boundary, so the JSON header is
  // only added for the string bodies that need it.
  const headers =
    typeof init?.body === 'string'
      ? { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }
      : init?.headers;

  try {
    return await fetch(`${API_BASE_URL}${path}`, { ...init, headers });
  } catch {
    throw unreachable();
  }
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await send(path, init);

  let body: CbhResponseWrapper<T> | null = null;
  try {
    body = (await response.json()) as CbhResponseWrapper<T>;
  } catch {
    body = null;
  }

  if (!response.ok || body?.successful === false) {
    throw new ApiError(
      body?.error?.message ?? `The check service returned status ${response.status}.`,
      response.status
    );
  }

  if (body === null || body.data === null || body.data === undefined) {
    throw new ApiError('The check service returned an empty response.', response.status);
  }

  return body.data;
}

export function checkLine(payload: LineCheckRequest): Promise<LineCheckResponse> {
  return request<LineCheckResponse>('/api/linecheck/line', {
    method: 'POST',
    body: JSON.stringify(payload),
  });
}

export function getReferenceStatus(): Promise<ReferenceDataStatus> {
  return request<ReferenceDataStatus>('/api/taric/status');
}

export function listClientProfiles(): Promise<AllObjectsResponse<ClientProfile>> {
  return request<AllObjectsResponse<ClientProfile>>('/api/client?pageSize=100');
}

/**
 * The header row is the profile's, not row 1. A Softpak pack carries five rows
 * of declaration metadata above its column headers, and reading row 1 reports
 * every required field as missing on a file that processes perfectly.
 */
export function suggestMapping(
  file: File,
  headerRow: number
): Promise<ColumnMappingSuggestion> {
  const form = new FormData();
  form.append('file', file);
  return request<ColumnMappingSuggestion>(
    `/api/client/suggest-mapping?headerRow=${headerRow}`,
    { method: 'POST', body: form }
  );
}

export function uploadBatch(options: UploadOptions): Promise<BatchResponse> {
  const form = new FormData();
  form.append('clientProfileId', options.clientProfileId);
  form.append('file', options.file);
  if (options.name) form.append('name', options.name);
  if (options.checkDate) form.append('checkDate', options.checkDate);
  if (options.destinationCountry) form.append('destinationCountry', options.destinationCountry);
  if (options.direction !== undefined) form.append('direction', String(options.direction));
  if (options.shipmentReference) form.append('shipmentReference', options.shipmentReference);

  return request<BatchResponse>('/api/batch/upload', { method: 'POST', body: form });
}

export function listBatches(): Promise<AllObjectsResponse<BatchResponse>> {
  return request<AllObjectsResponse<BatchResponse>>('/api/batch?pageSize=25');
}

export function getReviewTable(
  batchId: string,
  query: ReviewQuery = {}
): Promise<ReviewTableResponse> {
  const params = new URLSearchParams();
  if (query.needsReviewOnly) params.set('needsReviewOnly', 'true');
  if (query.confidence !== undefined) params.set('confidence', String(query.confidence));
  params.set('pageSize', String(query.pageSize ?? 100));
  params.set('pageIndex', String(query.pageIndex ?? 0));

  return request<ReviewTableResponse>(`/api/batch/${batchId}/review?${params}`);
}

export function reviewRow(
  batchId: string,
  rowIndex: number,
  payload: RowReviewRequest
): Promise<ReviewRow> {
  return request<ReviewRow>(`/api/batch/${batchId}/rows/${rowIndex}`, {
    method: 'PATCH',
    body: JSON.stringify(payload),
  });
}

export function makeRuleFromRow(
  batchId: string,
  rowIndex: number,
  payload: MakeRuleRequest
): Promise<RuleResponse> {
  return request<RuleResponse>(`/api/batch/${batchId}/rows/${rowIndex}/make-rule`, {
    method: 'POST',
    body: JSON.stringify(payload),
  });
}

export function recheckBatch(batchId: string): Promise<BatchResponse> {
  return request<BatchResponse>(`/api/batch/${batchId}/recheck`, { method: 'POST' });
}

export function startReview(batchId: string): Promise<BatchResponse> {
  return request<BatchResponse>(`/api/batch/${batchId}/review/start`, { method: 'POST' });
}

export function finishReview(batchId: string): Promise<BatchResponse> {
  return request<BatchResponse>(`/api/batch/${batchId}/review/finish`, { method: 'POST' });
}

export function listExportFormats(
  clientProfileId?: string
): Promise<ExportFormatResponse[]> {
  const query = clientProfileId
    ? `?clientProfileId=${encodeURIComponent(clientProfileId)}`
    : '';
  return request<ExportFormatResponse[]>(`/api/batch/export-formats${query}`);
}

/**
 * The export is a file, not an envelope — but a refused format still answers
 * with one (501 plus the reason the layout was not guessed at), so a failure
 * is read as JSON and surfaced as its own message.
 */
export async function downloadExport(
  batchId: string,
  format: ExportFormat
): Promise<{ blob: Blob; fileName: string }> {
  const response = await send(`/api/batch/${batchId}/export?format=${format}`);

  if (!response.ok) {
    let message = `The export failed with status ${response.status}.`;
    try {
      const body = (await response.json()) as CbhResponseWrapper<unknown>;
      if (body?.error?.message) message = body.error.message;
    } catch {
      /* the body was not an envelope; the status message stands */
    }
    throw new ApiError(message, response.status);
  }

  const disposition = response.headers.get('content-disposition') ?? '';
  const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition);

  return {
    blob: await response.blob(),
    fileName: match ? decodeURIComponent(match[1]) : `shipment_${batchId}.xlsx`,
  };
}