Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/shopify-app/app/features/delivery/route-detail.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fetchDeliveryOrders, syncDeliveryOrders } from "./orders.server";
import {
deleteDeliveryRouteGroup,
fetchDeliveryRouteGroupDetail,
fetchNextDeliveryRouteGroupRouteIdx,
previewDeliveryRouteGroupOptimization,
saveDeliveryRouteGroupDraft,
} from "./route-groups.server";
Expand Down Expand Up @@ -544,6 +545,27 @@ export const routeDetailAction = async ({ params, request }) => {
return result;
}

if (intent === "queryNextRouteIdx") {
const tempId = textOrUndefined(formData.get("tempId")) ?? null;
logRouteDetailPerformance("routes.detail.action.queryNextRouteIdx.request", {
routeGroupId,
routeId,
tempId,
});
const result = await fetchNextDeliveryRouteGroupRouteIdx(
request,
routeGroupId,
{ sessionToken: shopifySessionToken },
);
logRouteDetailPerformance("routes.detail.action.queryNextRouteIdx.done", {
errorCount: result.errors.length,
nextRouteIdx: result.nextRouteIdx,
routeGroupId,
routeId,
});
return { ...result, intent: "queryNextRouteIdx", tempId };
}

return {
routePlan: null,
stops: [],
Expand Down
26 changes: 26 additions & 0 deletions apps/shopify-app/app/features/delivery/route-groups.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,27 @@ export async function saveDeliveryRouteGroupDraft(request, routeGroupId, payload
return mutateRouteGroup(request, routeGroupId, "/draft", payload, options, "저장할 route group ID가 없습니다.");
}

export async function fetchNextDeliveryRouteGroupRouteIdx(request, routeGroupId, options = {}) {
const safeRouteGroupId = encodeURIComponent(routeGroupId ?? "");
if (!safeRouteGroupId) return { nextRouteIdx: null, errors: missingRouteGroupResult("조회할 route group ID가 없습니다.").errors };

const result = await deliveryApiRequest(request, `/admin/route-groups/${safeRouteGroupId}/next-route-idx`, {
cacheKey: `next-route-idx:${Date.now()}:${Math.random()}`,
fetch: options.fetch,
method: "GET",
sessionToken: options.sessionToken,
});

return {
nextRouteIdx: numberOrNull(
result.data?.nextRouteIdx
?? result.data?.nextRouteIndex
?? result.data?.routeIdx,
),
errors: result.errors,
};
}

export async function deleteDeliveryRouteGroup(request, routeGroupId, options = {}) {
const safeRouteGroupId = encodeURIComponent(routeGroupId ?? "");
if (!safeRouteGroupId) return missingRouteGroupResult("삭제할 route group ID가 없습니다.");
Expand Down Expand Up @@ -339,6 +360,11 @@ function uniqueTexts(values) {
return Array.from(new Set(values.map(textOrUndefined).filter(Boolean)));
}

function numberOrNull(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}

export async function generateDeliveryRouteGroupChildRoutes(request, routeGroupId, payload = {}, options = {}) {
const safeRouteGroupId = encodeURIComponent(routeGroupId ?? "");
if (!safeRouteGroupId) return missingRouteGroupResult("생성할 route group ID가 없습니다.");
Expand Down
138 changes: 92 additions & 46 deletions apps/shopify-app/app/routes/app.routes.$routeId.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2084,6 +2084,7 @@ function buildUnsplitRouteGroupRow(routeGroup, routeStops = []) {
driveTimeLabel: ROUTE_EMPTY_LABEL,
id: `routeGroup:${routeGroup.id}:routeIdx:1`,
isCurrent: false,
isGeneratedTitle: true,
isPreviewOnly: true,
optimized: null,
orderIds: routeStops.map((stop) => stop.orderId).filter(Boolean),
Expand Down Expand Up @@ -2179,6 +2180,7 @@ function applyRouteRowDraftState(routeRows, routeLineEdits, routePreviewByKey) {
: routeRow.scheduledStartTimeZone,
startDateTime: routeLineEdit.startDateTime ?? routeRow.startDateTime,
startTimeLabel: routeLineEdit.startTimeLabel ?? routeRow.startTimeLabel,
isGeneratedTitle: Object.hasOwn(routeLineEdit, "title") ? false : routeRow.isGeneratedTitle === true,
title: routeLineEdit.title ?? routeRow.title,
};
});
Expand Down Expand Up @@ -2230,6 +2232,7 @@ function getNextChildRouteDraft(routeRows) {
const routeNumber = (maxRouteIdx || routeRows.length) + 1;
return {
color: getUnusedRouteColor(null, usedColors, routeNumber - 1),
isGeneratedTitle: true,
label: `#${routeNumber}`,
routeIdx: routeNumber,
routeIndex: routeNumber,
Expand Down Expand Up @@ -2341,6 +2344,10 @@ function shouldIncludeRouteDraftRow(routeRow, includeEmptyTempRoutes) {
return !(routeRow.tempId && !routeRow.routePlanId && routeRow.stops.length === 0);
}

function getRouteDraftLabel(routeRow) {
return routeRow.isGeneratedTitle ? null : routeRow.title;
}

function buildRouteDraftPayload(routeRows, {
deletedRoutePlanIds = [],
expectedUpdatedAt,
Expand All @@ -2364,7 +2371,7 @@ function buildRouteDraftPayload(routeRows, {
driverId: routeRow.driverId ?? null,
...(routeRow.expectedChildUpdatedAt ? { expectedChildUpdatedAt: routeRow.expectedChildUpdatedAt } : {}),
...(routeRow.expectedRoutePlanUpdatedAt ? { expectedRoutePlanUpdatedAt: routeRow.expectedRoutePlanUpdatedAt } : {}),
label: routeRow.title,
label: getRouteDraftLabel(routeRow),
...(optimized === undefined ? {} : { optimized }),
orderIds: routeRow.stops.map((stop) => stop.orderId).filter(Boolean),
routeKey: getRouteRowDraftKey(routeRow),
Expand Down Expand Up @@ -2534,7 +2541,7 @@ export default function RouteDetailPage() {
const routeGroupActionBusy = routeActionFetcher.state !== "idle";
const routeGroupActionIntent = routeActionFetcher.formData?.get("_intent");
const reOptimizeRouteGroupBusy = routeGroupActionBusy && routeGroupActionIntent === "previewRouteOptimization";
const addEmptyRouteBranchBusy = false;
const addEmptyRouteBranchBusy = routeGroupActionBusy && routeGroupActionIntent === "queryNextRouteIdx";
const saveRouteDraftBusy = routeGroupActionBusy && routeGroupActionIntent === "saveRouteDraft";
const deleteRouteBusy = routeGroupActionBusy && routeGroupActionIntent === "deleteRoute";
const refreshRouteOrdersBusy = routeGroupActionBusy && routeGroupActionIntent === "refreshRouteOrders";
Expand Down Expand Up @@ -2711,6 +2718,11 @@ export default function RouteDetailPage() {
|| Object.keys(routeLineEdits).length > 0
|| Object.keys(routePreviewByKey).length > 0
|| removedOrderIds.length > 0;
const hasIncompatibleAddEmptyDraft = Object.keys(routeTimelineOrderByRouteId).length > 0
|| deletedRoutePlanIds.length > 0
|| Object.keys(routeLineEdits).length > 0
|| Object.keys(routePreviewByKey).length > 0
|| removedOrderIds.length > 0;
const canSaveRouteDraft = hasEditableRouteRows
&& hasRouteAllocationDraft
&& !routeGroupActionBusy
Expand Down Expand Up @@ -3628,58 +3640,59 @@ export default function RouteDetailPage() {
}, []);

const handleAddEmptyRoute = () => {
const previewRouteRow = contextRouteRows.find((routeRow) => routeRow.isPreviewOnly);
if (previewRouteRow) {
const tempId = `temp:${Date.now()}-${Math.random().toString(16).slice(2)}`;
setClientRouteRows((rows) => [
...rows,
{
...previewRouteRow,
id: tempId,
isMaterializedDraft: true,
isPreviewOnly: false,
routeKey: tempId,
routePlanId: null,
routeIdx: previewRouteRow.routeIdx,
stops: previewRouteRow.stops,
tempId,
},
]);
if (routeGroupActionBusy) return;
if (hasIncompatibleAddEmptyDraft) {
setRouteGroupClientError("저장하지 않은 Route 변경을 먼저 Save 또는 Revert 해주세요.");
return;
}

const draft = getNextChildRouteDraft(contextRouteRows);
const tempId = `temp:${Date.now()}-${Math.random().toString(16).slice(2)}`;
setClientRouteRows((rows) => [
...rows,
{
attemptedCount: 0,
color: draft.color,
createdLabel: ROUTE_EMPTY_LABEL,
startDateTime: "",
deliveredCount: 0,
driverId: null,
driverLabel: "Unassigned",
driveTimeLabel: ROUTE_EMPTY_LABEL,
const previewRouteRow = contextRouteRows.find((routeRow) => routeRow.isPreviewOnly);
const routeRow = previewRouteRow
? {
...previewRouteRow,
id: tempId,
isCurrent: false,
orderIds: [],
isGeneratedTitle: previewRouteRow.isGeneratedTitle === true,
isMaterializedDraft: true,
isPreviewOnly: false,
routeKey: tempId,
routeIdx: draft.routeIdx,
routeIndex: draft.routeIndex,
routePlanId: null,
scheduledStartAt: null,
scheduledStartTimeZone: null,
startTimeLabel: ROUTE_EMPTY_LABEL,
stops: [],
stopsCount: 0,
tempId,
title: draft.label,
totalDistanceLabel: ROUTE_EMPTY_LABEL,
totalItems: 0,
totalWeightLabel: ROUTE_EMPTY_LABEL,
},
]);
}
: (() => {
const draft = getNextChildRouteDraft(contextRouteRows);
return {
attemptedCount: 0,
color: draft.color,
createdLabel: ROUTE_EMPTY_LABEL,
startDateTime: "",
deliveredCount: 0,
driverId: null,
driverLabel: "Unassigned",
driveTimeLabel: ROUTE_EMPTY_LABEL,
id: tempId,
isCurrent: false,
isGeneratedTitle: draft.isGeneratedTitle === true,
isMaterializedDraft: false,
orderIds: [],
routeKey: tempId,
routeIdx: draft.routeIdx,
routeIndex: draft.routeIndex,
routePlanId: null,
scheduledStartAt: null,
scheduledStartTimeZone: null,
startTimeLabel: ROUTE_EMPTY_LABEL,
stops: [],
stopsCount: 0,
tempId,
title: draft.label,
totalDistanceLabel: ROUTE_EMPTY_LABEL,
totalItems: 0,
totalWeightLabel: ROUTE_EMPTY_LABEL,
};
})();
setClientRouteRows((rows) => [...rows, routeRow]);
submitRouteGroupAction("queryNextRouteIdx", { tempId });
};

const handlePreviewRouteOptimization = () => {
Expand Down Expand Up @@ -3850,6 +3863,39 @@ export default function RouteDetailPage() {
shopify.toast.show(`${updatedOrders} orders updated across ${refreshedRoutes} routes${skippedMessage}`);
}, [revalidator, routeActionFetcher.data, routeActionFetcher.state, shopify]);

useEffect(() => {
if (routeActionFetcher.state !== "idle" || routeActionFetcher.data === undefined) return;
if (lastRouteActionIntentRef.current !== "queryNextRouteIdx") return;
lastRouteActionIntentRef.current = null;

const errors = routeActionFetcher.data?.errors ?? [];
const tempId = routeActionFetcher.data?.tempId;
const nextRouteIdx = numberOrUndefined(routeActionFetcher.data?.nextRouteIdx);
if (errors.length > 0 || !tempId || nextRouteIdx === undefined) {
setClientRouteRows((rows) => tempId ? rows.filter((routeRow) => routeRow.tempId !== tempId) : rows);
setRouteGroupClientError(errors[0]?.message ?? "다음 route 번호를 조회하지 못했습니다.");
return;
}

setClientRouteRows((rows) => rows.map((routeRow) => {
if (routeRow.tempId !== tempId) return routeRow;
const routeIdx = Math.max(
nextRouteIdx,
numberOrUndefined(routeRow.routeIdx) ?? numberOrUndefined(routeRow.routeIndex) ?? nextRouteIdx,
);
const routeLineEdit = routeLineEdits[routeRow.id] ?? {};
const isGeneratedTitle = Object.hasOwn(routeLineEdit, "title") ? false : routeRow.isGeneratedTitle === true;
const title = isGeneratedTitle ? `#${routeIdx}` : routeRow.title;
return {
...routeRow,
isGeneratedTitle,
routeIdx,
routeIndex: routeIdx,
title,
};
}));
}, [routeActionFetcher.data, routeActionFetcher.state, routeLineEdits]);

useEffect(() => {
if (routeActionFetcher.state !== "idle" || routeActionFetcher.data === undefined) return;
if (lastRouteActionIntentRef.current !== "saveRouteDraft") return;
Expand Down
15 changes: 15 additions & 0 deletions apps/shopify-app/tests/route-groups-helper.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
deleteDeliveryRouteGroupChildRoute,
DELIVERY_ROUTE_GROUP_ID_MISSING_ERROR_CODE,
fetchDeliveryRouteGroups,
fetchNextDeliveryRouteGroupRouteIdx,
generateDeliveryRouteGroupChildRoutes,
previewDeliveryRouteGroupOptimization,
saveDeliveryRouteGroupDraft,
Expand Down Expand Up @@ -158,6 +159,20 @@ test("route group helper saves a batched draft allocation", async () => {
assert.deepEqual(JSON.parse(fakeFetch.calls[0].init.body), payload);
});

test("route group helper queries the next child route index without saving", async () => {
const fakeFetch = makeFetch({ data: { nextRouteIdx: 7 }, error: null });

const result = await fetchNextDeliveryRouteGroupRouteIdx(makeRequest(), "group/1", {
fetch: fakeFetch,
sessionToken: "session-token",
});

assert.deepEqual(result, { nextRouteIdx: 7, errors: [] });
assert.equal(fakeFetch.calls[0].url, "https://delivery.test/admin/route-groups/group%2F1/next-route-idx");
assert.equal(fakeFetch.calls[0].init.method, "GET");
assert.equal(fakeFetch.calls[0].init.body, undefined);
});

test("route group child delete draft merges deleted child orders into the first route", () => {
const group = {
id: "group/1",
Expand Down
Loading