chore: prepare release build
This commit is contained in:
@@ -104,7 +104,14 @@ export interface AdminWarehouseWorkbenchContext {
|
|||||||
operator_name: string;
|
operator_name: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
inbound_attachments?: AdminFileAsset[];
|
||||||
|
packing_attachments?: AdminFileAsset[];
|
||||||
}>;
|
}>;
|
||||||
|
return_verification?: {
|
||||||
|
verified: boolean;
|
||||||
|
report_id: number;
|
||||||
|
report_no: string;
|
||||||
|
};
|
||||||
next_action?: string;
|
next_action?: string;
|
||||||
next_action_text?: string;
|
next_action_text?: string;
|
||||||
}
|
}
|
||||||
@@ -244,6 +251,66 @@ export interface AdminOrderWarehouseOption {
|
|||||||
supported_category_names: string[];
|
supported_category_names: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminManualOrderMaterialItem {
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
is_required: boolean;
|
||||||
|
files: AdminFileAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminManualOrderCreatePayload {
|
||||||
|
service_provider: string;
|
||||||
|
product_info: {
|
||||||
|
category_id: number;
|
||||||
|
brand_id: number;
|
||||||
|
product_name: string;
|
||||||
|
color: string;
|
||||||
|
size_spec: string;
|
||||||
|
serial_no: string;
|
||||||
|
};
|
||||||
|
extra_info: {
|
||||||
|
purchase_channel: string;
|
||||||
|
purchase_price: number;
|
||||||
|
usage_status: string;
|
||||||
|
condition_desc: string;
|
||||||
|
remark: string;
|
||||||
|
};
|
||||||
|
return_address: {
|
||||||
|
consignee: string;
|
||||||
|
mobile: string;
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
detail_address: string;
|
||||||
|
};
|
||||||
|
materials: AdminManualOrderMaterialItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminManualOrderCreateResponse {
|
||||||
|
order_id: number;
|
||||||
|
order_no: string;
|
||||||
|
appraisal_no: string;
|
||||||
|
user_id: number;
|
||||||
|
next_status: "pending_shipping";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminManualOrderMeta {
|
||||||
|
categories: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
supported_service_types: string[];
|
||||||
|
}>;
|
||||||
|
brands: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
en_name: string;
|
||||||
|
code: string;
|
||||||
|
category_ids: number[];
|
||||||
|
supported_service_types: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CatalogOverviewCard {
|
export interface CatalogOverviewCard {
|
||||||
title: string;
|
title: string;
|
||||||
value: number;
|
value: number;
|
||||||
@@ -380,6 +447,9 @@ export interface AdminReportListItem {
|
|||||||
product_name: string;
|
product_name: string;
|
||||||
category_name: string;
|
category_name: string;
|
||||||
brand_name: string;
|
brand_name: string;
|
||||||
|
material_tag_bound: boolean;
|
||||||
|
material_tag_verify_code: string;
|
||||||
|
material_tag_bind_status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminReportDetail {
|
export interface AdminReportDetail {
|
||||||
@@ -414,6 +484,7 @@ export interface AdminReportDetail {
|
|||||||
mime_type?: string;
|
mime_type?: string;
|
||||||
}>;
|
}>;
|
||||||
zhongjian_report_files: AdminFileAsset[];
|
zhongjian_report_files: AdminFileAsset[];
|
||||||
|
material_tag: null | AdminMaterialTagCode;
|
||||||
risk_notice_text: string;
|
risk_notice_text: string;
|
||||||
verify_info: {
|
verify_info: {
|
||||||
verify_status: string;
|
verify_status: string;
|
||||||
@@ -705,6 +776,7 @@ export interface AdminAppraisalTaskResultPayload {
|
|||||||
}>;
|
}>;
|
||||||
external_remark: string;
|
external_remark: string;
|
||||||
internal_remark: string;
|
internal_remark: string;
|
||||||
|
qr_input?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAppraisalTaskSupplementPayload {
|
export interface AdminAppraisalTaskSupplementPayload {
|
||||||
@@ -1450,6 +1522,33 @@ export const adminApi = {
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
|
createManualOrder(data: AdminManualOrderCreatePayload) {
|
||||||
|
return request.post("/api/admin/manual-order/create", data) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: AdminManualOrderCreateResponse;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
getManualOrderMeta() {
|
||||||
|
return request.get("/api/admin/manual-order/meta") as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: AdminManualOrderMeta;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
uploadManualOrderFile(file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
return request.post("/api/admin/manual-order/file/upload", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
}) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: AdminFileAsset;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
getCatalogOverview() {
|
getCatalogOverview() {
|
||||||
return request.get("/api/admin/catalog/overview") as Promise<{
|
return request.get("/api/admin/catalog/overview") as Promise<{
|
||||||
code: number;
|
code: number;
|
||||||
@@ -1588,13 +1687,14 @@ export const adminApi = {
|
|||||||
data: AdminReportDetail;
|
data: AdminReportDetail;
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
publishReport(id: number) {
|
publishReport(id: number, qrInput = "") {
|
||||||
return request.post("/api/admin/report/publish", {
|
return request.post("/api/admin/report/publish", {
|
||||||
id,
|
id,
|
||||||
|
qr_input: qrInput,
|
||||||
}) as Promise<{
|
}) as Promise<{
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
data: AdminPublishReportResponse;
|
data: AdminPublishReportResponse & { material_tag?: AdminMaterialTagCode | null };
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
saveInspectionReport(data: AdminManualInspectionPayload) {
|
saveInspectionReport(data: AdminManualInspectionPayload) {
|
||||||
@@ -1689,7 +1789,7 @@ export const adminApi = {
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[] }) {
|
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[]; qr_input: string }) {
|
||||||
return request.post("/api/admin/appraisal-task/zhongjian-report/save", data) as Promise<{
|
return request.post("/api/admin/appraisal-task/zhongjian-report/save", data) as Promise<{
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -1728,16 +1828,16 @@ export const adminApi = {
|
|||||||
data: { file_url: string };
|
data: { file_url: string };
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
lookupWarehouseInbound(trackingNo: string) {
|
lookupWarehouseInbound(inboundNo: string) {
|
||||||
return request.get("/api/admin/warehouse-workbench/inbound/lookup", {
|
return request.get("/api/admin/warehouse-workbench/inbound/lookup", {
|
||||||
params: { tracking_no: trackingNo },
|
params: { inbound_no: inboundNo },
|
||||||
}) as Promise<{
|
}) as Promise<{
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
data: AdminWarehouseWorkbenchContext;
|
data: AdminWarehouseWorkbenchContext;
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
receiveWarehouseInbound(data: { tracking_no: string; internal_tag_no: string }) {
|
receiveWarehouseInbound(data: { inbound_no?: string; tracking_no?: string; internal_tag_no: string; inbound_attachments?: AdminFileAsset[] }) {
|
||||||
return request.post("/api/admin/warehouse-workbench/inbound/receive", data) as Promise<{
|
return request.post("/api/admin/warehouse-workbench/inbound/receive", data) as Promise<{
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -1775,12 +1875,32 @@ export const adminApi = {
|
|||||||
data: AdminWarehouseWorkbenchContext;
|
data: AdminWarehouseWorkbenchContext;
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
|
confirmWarehouseReturnReport(data: { internal_tag_no: string; report_id: number }) {
|
||||||
|
return request.post("/api/admin/warehouse-workbench/return/report/confirm", data) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: AdminWarehouseWorkbenchContext;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
confirmWarehouseReturnZhongjian(internalTagNo: string) {
|
confirmWarehouseReturnZhongjian(internalTagNo: string) {
|
||||||
return request.post("/api/admin/warehouse-workbench/return/zhongjian/confirm", {
|
return request.post("/api/admin/warehouse-workbench/return/zhongjian/confirm", {
|
||||||
internal_tag_no: internalTagNo,
|
internal_tag_no: internalTagNo,
|
||||||
}) as Promise<{ code: number; message: string; data: AdminWarehouseWorkbenchContext }>;
|
}) as Promise<{ code: number; message: string; data: AdminWarehouseWorkbenchContext }>;
|
||||||
},
|
},
|
||||||
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string }) {
|
uploadWarehouseReturnPackingFile(file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
return request.post("/api/admin/warehouse-workbench/return/packing/upload", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
}) as Promise<{
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: AdminFileAsset;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string; packing_attachments?: AdminFileAsset[] }) {
|
||||||
return request.post("/api/admin/warehouse-workbench/return/ship", data) as Promise<{
|
return request.post("/api/admin/warehouse-workbench/return/ship", data) as Promise<{
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { ElMessage, type InputInstance } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import {
|
import {
|
||||||
adminApi,
|
adminApi,
|
||||||
type AdminFileAsset,
|
type AdminFileAsset,
|
||||||
@@ -27,10 +27,6 @@ const evidenceUploading = ref(false);
|
|||||||
const appraisalTemplateLoading = ref(false);
|
const appraisalTemplateLoading = ref(false);
|
||||||
const transferTagNo = ref("");
|
const transferTagNo = ref("");
|
||||||
const transferScanLoading = ref(false);
|
const transferScanLoading = ref(false);
|
||||||
const publishDialogVisible = ref(false);
|
|
||||||
const publishMaterialTagInput = ref("");
|
|
||||||
const publishMaterialTagInputRef = ref<InputInstance | null>(null);
|
|
||||||
const publishMaterialTagSubmitting = ref(false);
|
|
||||||
const zhongjianReportNo = ref("");
|
const zhongjianReportNo = ref("");
|
||||||
const zhongjianReportFiles = ref<AdminFileAsset[]>([]);
|
const zhongjianReportFiles = ref<AdminFileAsset[]>([]);
|
||||||
const zhongjianReportUploading = ref(false);
|
const zhongjianReportUploading = ref(false);
|
||||||
@@ -277,14 +273,10 @@ const canBindMaterialTag = computed(() => {
|
|||||||
if (!detail.value?.report_summary) {
|
if (!detail.value?.report_summary) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (detail.value.task_info.service_provider === "zhongjian") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return detail.value.report_summary.report_status !== "published" && !detail.value.material_tag;
|
return detail.value.report_summary.report_status !== "published" && !detail.value.material_tag;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isZhongjianTask = computed(() => detail.value?.task_info.service_provider === "zhongjian");
|
const isZhongjianTask = computed(() => detail.value?.task_info.service_provider === "zhongjian");
|
||||||
const isPhysicalTask = computed(() => Boolean(detail.value) && !isZhongjianTask.value);
|
|
||||||
const canRequestSupplement = computed(() => detail.value?.task_info.status !== "completed");
|
const canRequestSupplement = computed(() => detail.value?.task_info.status !== "completed");
|
||||||
const currentAdmin = computed(() => getAdminInfo());
|
const currentAdmin = computed(() => getAdminInfo());
|
||||||
const canClaimTask = computed(() => {
|
const canClaimTask = computed(() => {
|
||||||
@@ -783,6 +775,13 @@ async function submitResult(action: "save" | "submit") {
|
|||||||
if (action === "submit" && !validateRequiredKeyPoints()) {
|
if (action === "submit" && !validateRequiredKeyPoints()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let qrInput = "";
|
||||||
|
if (action === "submit") {
|
||||||
|
qrInput = await promptPublishMaterialTagInput();
|
||||||
|
if (!qrInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
resultSubmitting.value = true;
|
resultSubmitting.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.saveAppraisalTaskResult({
|
const response = await adminApi.saveAppraisalTaskResult({
|
||||||
@@ -792,16 +791,11 @@ async function submitResult(action: "save" | "submit") {
|
|||||||
...resultForm,
|
...resultForm,
|
||||||
attachments: resultAttachments.value,
|
attachments: resultAttachments.value,
|
||||||
key_points: normalizedKeyPoints(),
|
key_points: normalizedKeyPoints(),
|
||||||
|
...(qrInput ? { qr_input: qrInput } : {}),
|
||||||
});
|
});
|
||||||
ElMessage.success(response.message || (action === "submit" ? "结论已提交" : "结论已保存"));
|
ElMessage.success(response.message || (action === "submit" ? "验真吊牌已绑定,报告已发布" : "结论已保存"));
|
||||||
await loadDetail(detail.value.task_info.id);
|
await loadDetail(detail.value.task_info.id);
|
||||||
await fetchTasks();
|
await fetchTasks();
|
||||||
if (action === "submit") {
|
|
||||||
publishMaterialTagInput.value = "";
|
|
||||||
publishDialogVisible.value = true;
|
|
||||||
await nextTick();
|
|
||||||
publishMaterialTagInputRef.value?.focus();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
ElMessage.error(action === "submit" ? "结论提交失败" : "结论保存失败");
|
ElMessage.error(action === "submit" ? "结论提交失败" : "结论保存失败");
|
||||||
@@ -812,10 +806,6 @@ async function submitResult(action: "save" | "submit") {
|
|||||||
|
|
||||||
async function publishCurrentTaskWithMaterialTag(qrInput: string) {
|
async function publishCurrentTaskWithMaterialTag(qrInput: string) {
|
||||||
if (!detail.value) return;
|
if (!detail.value) return;
|
||||||
if (!isPhysicalTask.value) {
|
|
||||||
ElMessage.warning("中检订单不使用平台验真吊牌");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await adminApi.publishAppraisalTaskWithMaterialTag({
|
await adminApi.publishAppraisalTaskWithMaterialTag({
|
||||||
id: detail.value.task_info.id,
|
id: detail.value.task_info.id,
|
||||||
@@ -845,30 +835,21 @@ async function bindMaterialTag() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishDialogMaterialTag() {
|
async function promptPublishMaterialTagInput() {
|
||||||
const qrInput = publishMaterialTagInput.value.trim();
|
|
||||||
if (!qrInput) {
|
|
||||||
ElMessage.warning("请扫描验真吊牌二维码");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
publishMaterialTagSubmitting.value = true;
|
|
||||||
try {
|
try {
|
||||||
await publishCurrentTaskWithMaterialTag(qrInput);
|
const result = await ElMessageBox.prompt("是否已鉴定完成并确定发布报告?", "绑定验真吊牌并发布报告", {
|
||||||
publishDialogVisible.value = false;
|
type: "warning",
|
||||||
publishMaterialTagInput.value = "";
|
inputPlaceholder: "请扫描验真吊牌二维码",
|
||||||
} catch (error: any) {
|
inputPattern: /\S+/,
|
||||||
console.error(error);
|
inputErrorMessage: "请扫描验真吊牌二维码",
|
||||||
ElMessage.error(error?.message || "验真吊牌绑定或报告发布失败");
|
confirmButtonText: "是的,去绑定验真吊牌",
|
||||||
} finally {
|
cancelButtonText: "取消",
|
||||||
publishMaterialTagSubmitting.value = false;
|
closeOnClickModal: false,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusPublishMaterialTagInput() {
|
|
||||||
nextTick(() => {
|
|
||||||
publishMaterialTagInputRef.value?.focus();
|
|
||||||
});
|
});
|
||||||
|
return String(result.value || "").trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitZhongjianReport() {
|
async function submitZhongjianReport() {
|
||||||
@@ -885,6 +866,10 @@ async function submitZhongjianReport() {
|
|||||||
ElMessage.warning("请至少上传 1 个中检报告文件");
|
ElMessage.warning("请至少上传 1 个中检报告文件");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const qrInput = await promptPublishMaterialTagInput();
|
||||||
|
if (!qrInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
zhongjianReportSubmitting.value = true;
|
zhongjianReportSubmitting.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -892,8 +877,9 @@ async function submitZhongjianReport() {
|
|||||||
id: detail.value.task_info.id,
|
id: detail.value.task_info.id,
|
||||||
zhongjian_report_no: zhongjianReportNo.value.trim(),
|
zhongjian_report_no: zhongjianReportNo.value.trim(),
|
||||||
report_files: zhongjianReportFiles.value,
|
report_files: zhongjianReportFiles.value,
|
||||||
|
qr_input: qrInput,
|
||||||
});
|
});
|
||||||
ElMessage.success(response.message || "中检报告已录入并发布");
|
ElMessage.success(response.message || "验真吊牌已绑定,报告已发布");
|
||||||
await loadDetail(detail.value.task_info.id);
|
await loadDetail(detail.value.task_info.id);
|
||||||
await fetchTasks();
|
await fetchTasks();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -1428,7 +1414,7 @@ onMounted(async () => {
|
|||||||
<div :key="`result-${formRenderKey}`" class="task-form-stack">
|
<div :key="`result-${formRenderKey}`" class="task-form-stack">
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="isZhongjianTask"
|
v-if="isZhongjianTask"
|
||||||
title="中检订单不走平台验真吊牌流程,请切换到中检报告录入。"
|
title="中检订单请在中检报告录入页提交,提交时同样需要绑定验真吊牌。"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
@@ -1476,16 +1462,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div class="task-form-block">
|
<div class="task-form-block">
|
||||||
<div class="task-form-block__title">吊牌绑定</div>
|
<div class="task-form-block__title">吊牌绑定</div>
|
||||||
<div class="task-panel__desc">实物鉴定提交结论后扫描平台验真吊牌,绑定后发布报告。</div>
|
<div class="task-panel__desc">提交结论或中检报告时扫描平台验真吊牌,绑定成功后发布报告。</div>
|
||||||
<el-alert
|
<div v-if="detail.material_tag" class="task-material-tag-bound">
|
||||||
v-if="isZhongjianTask"
|
|
||||||
title="中检订单不使用本平台验真吊牌。"
|
|
||||||
type="warning"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
style="margin-top: 12px;"
|
|
||||||
/>
|
|
||||||
<div v-else-if="detail.material_tag" class="task-material-tag-bound">
|
|
||||||
<div class="task-info-grid">
|
<div class="task-info-grid">
|
||||||
<div class="task-info-item task-info-item--full">
|
<div class="task-info-item task-info-item--full">
|
||||||
<div class="task-info-item__label">二维码链接</div>
|
<div class="task-info-item__label">二维码链接</div>
|
||||||
@@ -1673,7 +1651,7 @@ onMounted(async () => {
|
|||||||
<el-tab-pane v-if="isZhongjianTask" label="中检报告录入" name="zhongjian">
|
<el-tab-pane v-if="isZhongjianTask" label="中检报告录入" name="zhongjian">
|
||||||
<div :key="`zhongjian-${formRenderKey}`" class="task-form-stack">
|
<div :key="`zhongjian-${formRenderKey}`" class="task-form-stack">
|
||||||
<el-alert
|
<el-alert
|
||||||
title="中检订单不绑定平台验真吊牌,提交中检报告编号和文件后直接发布报告。"
|
title="提交中检报告编号和文件后,需要扫描平台验真吊牌;绑定成功后才会发布报告。"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
@@ -1836,33 +1814,6 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
|
||||||
v-model="publishDialogVisible"
|
|
||||||
title="绑定验真吊牌并发布报告"
|
|
||||||
width="560px"
|
|
||||||
@opened="focusPublishMaterialTagInput"
|
|
||||||
>
|
|
||||||
<div class="publish-dialog-body">
|
|
||||||
<el-alert
|
|
||||||
title="请扫描物品验真吊牌二维码,回车后发布正式报告。"
|
|
||||||
type="info"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
/>
|
|
||||||
<el-input
|
|
||||||
ref="publishMaterialTagInputRef"
|
|
||||||
v-model="publishMaterialTagInput"
|
|
||||||
size="large"
|
|
||||||
placeholder="扫描平台验真吊牌二维码"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="publishDialogMaterialTag"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="publishDialogVisible = false">稍后处理</el-button>
|
|
||||||
<el-button type="primary" :loading="publishMaterialTagSubmitting" @click="publishDialogMaterialTag">完成并发布报告</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1895,11 +1846,6 @@ onMounted(async () => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-dialog-body {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.task-detail-drawer .el-drawer__body) {
|
:deep(.task-detail-drawer .el-drawer__body) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { adminApi, type AdminOrderDetail, type AdminOrderListItem, type AdminOrderWarehouseOption } from "../../api/admin";
|
import { adminApi, type AdminFileAsset, type AdminManualOrderCreatePayload, type AdminManualOrderMeta, type AdminOrderDetail, type AdminOrderListItem, type AdminOrderWarehouseOption } from "../../api/admin";
|
||||||
import OrderStatusTag from "../../components/OrderStatusTag.vue";
|
import OrderStatusTag from "../../components/OrderStatusTag.vue";
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -18,6 +18,12 @@ const returnDialogVisible = ref(false);
|
|||||||
const returnSubmitting = ref(false);
|
const returnSubmitting = ref(false);
|
||||||
const returnExpressCompany = ref("");
|
const returnExpressCompany = ref("");
|
||||||
const returnTrackingNo = ref("");
|
const returnTrackingNo = ref("");
|
||||||
|
const manualDialogVisible = ref(false);
|
||||||
|
const manualSubmitting = ref(false);
|
||||||
|
const manualMetaLoading = ref(false);
|
||||||
|
const manualUploading = ref(false);
|
||||||
|
const manualMeta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
|
||||||
|
const manualForm = ref<AdminManualOrderCreatePayload>(createManualOrderForm());
|
||||||
|
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const serviceProvider = ref("");
|
const serviceProvider = ref("");
|
||||||
@@ -48,6 +54,7 @@ const sourceChannelOptions = [
|
|||||||
{ label: "小程序", value: "mini_program" },
|
{ label: "小程序", value: "mini_program" },
|
||||||
{ label: "H5", value: "h5" },
|
{ label: "H5", value: "h5" },
|
||||||
{ label: "大客户推送订单", value: "enterprise_push" },
|
{ label: "大客户推送订单", value: "enterprise_push" },
|
||||||
|
{ label: "后台补录订单", value: "manual_entry" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const usageStatusMap: Record<string, string> = {
|
const usageStatusMap: Record<string, string> = {
|
||||||
@@ -107,6 +114,52 @@ const logisticsActionText = computed(() => {
|
|||||||
const canSubmitReturnLogistics = computed(() => Boolean(detail.value?.order_info.can_submit_return_logistics));
|
const canSubmitReturnLogistics = computed(() => Boolean(detail.value?.order_info.can_submit_return_logistics));
|
||||||
const returnLogisticsBlockReason = computed(() => detail.value?.order_info.return_logistics_block_reason || "");
|
const returnLogisticsBlockReason = computed(() => detail.value?.order_info.return_logistics_block_reason || "");
|
||||||
const canMarkReturnReceived = computed(() => Boolean(detail.value?.order_info.can_mark_return_received));
|
const canMarkReturnReceived = computed(() => Boolean(detail.value?.order_info.can_mark_return_received));
|
||||||
|
const manualBrandOptions = computed(() => {
|
||||||
|
const categoryId = manualForm.value.product_info.category_id;
|
||||||
|
const provider = manualForm.value.service_provider;
|
||||||
|
return manualMeta.value.brands.filter((item) => {
|
||||||
|
const categoryMatched = !categoryId || !item.category_ids.length || item.category_ids.includes(categoryId);
|
||||||
|
const providerMatched = !item.supported_service_types.length || item.supported_service_types.includes(provider);
|
||||||
|
return categoryMatched && providerMatched;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createManualOrderForm(): AdminManualOrderCreatePayload {
|
||||||
|
return {
|
||||||
|
service_provider: "anxinyan",
|
||||||
|
product_info: {
|
||||||
|
category_id: 0,
|
||||||
|
brand_id: 0,
|
||||||
|
product_name: "",
|
||||||
|
color: "",
|
||||||
|
size_spec: "",
|
||||||
|
serial_no: "",
|
||||||
|
},
|
||||||
|
extra_info: {
|
||||||
|
purchase_channel: "",
|
||||||
|
purchase_price: 0,
|
||||||
|
usage_status: "",
|
||||||
|
condition_desc: "",
|
||||||
|
remark: "",
|
||||||
|
},
|
||||||
|
return_address: {
|
||||||
|
consignee: "",
|
||||||
|
mobile: "",
|
||||||
|
province: "",
|
||||||
|
city: "",
|
||||||
|
district: "",
|
||||||
|
detail_address: "",
|
||||||
|
},
|
||||||
|
materials: [
|
||||||
|
{
|
||||||
|
item_code: "manual_initial",
|
||||||
|
item_name: "补录资料",
|
||||||
|
is_required: false,
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchOrders() {
|
async function fetchOrders() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -126,6 +179,82 @@ async function fetchOrders() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureManualMeta() {
|
||||||
|
if (manualMeta.value.categories.length && manualMeta.value.brands.length) return;
|
||||||
|
manualMetaLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.getManualOrderMeta();
|
||||||
|
manualMeta.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error("补录订单选项加载失败");
|
||||||
|
} finally {
|
||||||
|
manualMetaLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openManualDialog() {
|
||||||
|
manualForm.value = createManualOrderForm();
|
||||||
|
manualDialogVisible.value = true;
|
||||||
|
await ensureManualMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleManualCategoryChange() {
|
||||||
|
const selectedBrand = manualBrandOptions.value.find((item) => item.id === manualForm.value.product_info.brand_id);
|
||||||
|
if (!selectedBrand) {
|
||||||
|
manualForm.value.product_info.brand_id = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManualForm() {
|
||||||
|
const form = manualForm.value;
|
||||||
|
if (!form.product_info.category_id || !form.product_info.brand_id || !form.product_info.product_name.trim()) {
|
||||||
|
ElMessage.warning("请完整填写品类、品牌和商品名称");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const address = form.return_address;
|
||||||
|
if (!address.consignee.trim() || !address.mobile.trim() || !address.province.trim() || !address.city.trim() || !address.district.trim() || !address.detail_address.trim()) {
|
||||||
|
ElMessage.warning("请完整填写寄回收件信息");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadManualMaterial(options: { file: File }) {
|
||||||
|
manualUploading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.uploadManualOrderFile(options.file);
|
||||||
|
manualForm.value.materials[0].files.push(response.data);
|
||||||
|
ElMessage.success("资料已上传");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : "资料上传失败");
|
||||||
|
} finally {
|
||||||
|
manualUploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeManualMaterial(file: AdminFileAsset) {
|
||||||
|
manualForm.value.materials[0].files = manualForm.value.materials[0].files.filter((item) => item.file_url !== file.file_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitManualOrder() {
|
||||||
|
if (!validateManualForm()) return;
|
||||||
|
manualSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
const payload: AdminManualOrderCreatePayload = JSON.parse(JSON.stringify(manualForm.value));
|
||||||
|
const response = await adminApi.createManualOrder(payload);
|
||||||
|
ElMessage.success(`补录订单已创建:${response.data.order_no} / ${response.data.appraisal_no}`);
|
||||||
|
manualDialogVisible.value = false;
|
||||||
|
await fetchOrders();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : "补录订单创建失败");
|
||||||
|
} finally {
|
||||||
|
manualSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openDetail(row: AdminOrderListItem) {
|
async function openDetail(row: AdminOrderListItem) {
|
||||||
detailLoading.value = true;
|
detailLoading.value = true;
|
||||||
drawerVisible.value = true;
|
drawerVisible.value = true;
|
||||||
@@ -289,6 +418,7 @@ onMounted(fetchOrders);
|
|||||||
<el-option v-for="item in sourceChannelOptions" :key="item.value" :label="item.label" :value="item.value" />
|
<el-option v-for="item in sourceChannelOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" @click="fetchOrders">查询</el-button>
|
<el-button type="primary" @click="fetchOrders">查询</el-button>
|
||||||
|
<el-button @click="openManualDialog">补录订单</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -665,6 +795,124 @@ onMounted(fetchOrders);
|
|||||||
<el-button type="primary" :loading="returnSubmitting" :disabled="!canSubmitReturnLogistics" @click="submitReturnLogistics">确认登记</el-button>
|
<el-button type="primary" :loading="returnSubmitting" :disabled="!canSubmitReturnLogistics" @click="submitReturnLogistics">确认登记</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="manualDialogVisible" title="补录订单" width="860px" destroy-on-close>
|
||||||
|
<div v-loading="manualMetaLoading" class="manual-order-form">
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="补录订单创建后为待入库状态"
|
||||||
|
description="创建成功后,可在入库台使用订单号或鉴定单号匹配并绑定内部流转挂牌,不需要填写寄入快递单号。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="manual-section">
|
||||||
|
<div class="manual-section__title">订单与商品</div>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<div class="manual-grid">
|
||||||
|
<el-form-item label="服务类型">
|
||||||
|
<el-select v-model="manualForm.service_provider" style="width: 100%">
|
||||||
|
<el-option label="实物鉴定" value="anxinyan" />
|
||||||
|
<el-option label="中检鉴定" value="zhongjian" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="品类">
|
||||||
|
<el-select v-model="manualForm.product_info.category_id" filterable style="width: 100%" @change="handleManualCategoryChange">
|
||||||
|
<el-option v-for="item in manualMeta.categories" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="品牌">
|
||||||
|
<el-select v-model="manualForm.product_info.brand_id" filterable style="width: 100%">
|
||||||
|
<el-option v-for="item in manualBrandOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="商品名称">
|
||||||
|
<el-input v-model="manualForm.product_info.product_name" placeholder="例如:Classic Flap 手袋" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="颜色">
|
||||||
|
<el-input v-model="manualForm.product_info.color" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="规格 / 尺寸">
|
||||||
|
<el-input v-model="manualForm.product_info.size_spec" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="序列号">
|
||||||
|
<el-input v-model="manualForm.product_info.serial_no" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="购买渠道">
|
||||||
|
<el-input v-model="manualForm.extra_info.purchase_channel" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="购买价格">
|
||||||
|
<el-input-number v-model="manualForm.extra_info.purchase_price" :min="0" :precision="2" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="使用情况">
|
||||||
|
<el-select v-model="manualForm.extra_info.usage_status" clearable style="width: 100%">
|
||||||
|
<el-option label="全新未使用" value="new" />
|
||||||
|
<el-option label="轻微使用痕迹" value="light_use" />
|
||||||
|
<el-option label="长期使用" value="used" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="成色说明">
|
||||||
|
<el-input v-model="manualForm.extra_info.condition_desc" type="textarea" :rows="3" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="内部备注">
|
||||||
|
<el-input v-model="manualForm.extra_info.remark" type="textarea" :rows="3" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="manual-section">
|
||||||
|
<div class="manual-section__title">寄回信息</div>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<div class="manual-grid">
|
||||||
|
<el-form-item label="收件人">
|
||||||
|
<el-input v-model="manualForm.return_address.consignee" placeholder="用于匹配或创建用户" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机号">
|
||||||
|
<el-input v-model="manualForm.return_address.mobile" placeholder="按手机号复用已有用户" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="省份">
|
||||||
|
<el-input v-model="manualForm.return_address.province" placeholder="例如:广东省" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="城市">
|
||||||
|
<el-input v-model="manualForm.return_address.city" placeholder="例如:深圳市" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="区县">
|
||||||
|
<el-input v-model="manualForm.return_address.district" placeholder="例如:南山区" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="详细地址">
|
||||||
|
<el-input v-model="manualForm.return_address.detail_address" placeholder="街道、门牌号" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="manual-section">
|
||||||
|
<div class="manual-section__title">初始资料</div>
|
||||||
|
<div class="manual-upload-head">
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="uploadManualMaterial"
|
||||||
|
:disabled="manualUploading"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<el-button :loading="manualUploading">上传图片/视频/PDF</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<span class="manual-upload-hint">{{ manualForm.materials[0].files.length }} 个资料文件</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="manualForm.materials[0].files.length" class="manual-file-list">
|
||||||
|
<div v-for="file in manualForm.materials[0].files" :key="file.file_url" class="manual-file-item">
|
||||||
|
<span>{{ file.name || file.file_url }}</span>
|
||||||
|
<el-button link type="danger" @click="removeManualMaterial(file)">移除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="manualDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="manualSubmitting" :disabled="manualUploading || manualMetaLoading" @click="submitManualOrder">创建补录订单</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -789,6 +1037,65 @@ onMounted(fetchOrders);
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.manual-order-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-section__title {
|
||||||
|
color: var(--admin-text-main);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-upload-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-upload-hint {
|
||||||
|
color: var(--admin-text-subtle);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-file-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-file-item span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--admin-text-main);
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
.order-detail-hero {
|
.order-detail-hero {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -810,5 +1117,9 @@ onMounted(fetchOrders);
|
|||||||
.order-detail-grid {
|
.order-detail-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.manual-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -273,12 +273,44 @@ async function openDetailFromRouteQuery() {
|
|||||||
await loadDetail(reportId);
|
await loadDetail(reportId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishReport(row: Pick<AdminReportListItem, "id" | "report_status"> | { id: number; report_status: string }) {
|
type PublishReportTarget = Pick<AdminReportListItem, "id" | "report_status" | "report_type" | "material_tag_bound"> | {
|
||||||
|
id: number;
|
||||||
|
report_status: string;
|
||||||
|
report_type: string;
|
||||||
|
material_tag_bound: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function promptReportMaterialTagInput() {
|
||||||
|
try {
|
||||||
|
const result = await ElMessageBox.prompt("是否已鉴定完成并确定发布报告?", "绑定验真吊牌并发布报告", {
|
||||||
|
type: "warning",
|
||||||
|
inputPlaceholder: "请扫描验真吊牌二维码",
|
||||||
|
inputPattern: /\S+/,
|
||||||
|
inputErrorMessage: "请扫描验真吊牌二维码",
|
||||||
|
confirmButtonText: "是的,去绑定验真吊牌",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
closeOnClickModal: false,
|
||||||
|
});
|
||||||
|
return String(result.value || "").trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishReport(row: PublishReportTarget) {
|
||||||
if (row.report_status !== "pending_publish") {
|
if (row.report_status !== "pending_publish") {
|
||||||
ElMessage.warning("仅待发布报告可以执行发布");
|
ElMessage.warning("仅待发布报告可以执行发布");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needMaterialTag = row.report_type !== "inspection" && !row.material_tag_bound;
|
||||||
|
let qrInput = "";
|
||||||
|
if (needMaterialTag) {
|
||||||
|
qrInput = await promptReportMaterialTagInput();
|
||||||
|
if (!qrInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm("发布后用户端将可查看正式报告并进行验真,是否继续?", "发布报告", {
|
await ElMessageBox.confirm("发布后用户端将可查看正式报告并进行验真,是否继续?", "发布报告", {
|
||||||
type: "warning",
|
type: "warning",
|
||||||
@@ -288,10 +320,11 @@ async function publishReport(row: Pick<AdminReportListItem, "id" | "report_statu
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
publishingId.value = row.id;
|
publishingId.value = row.id;
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.publishReport(row.id);
|
const response = await adminApi.publishReport(row.id, qrInput);
|
||||||
if (response.code !== 0) {
|
if (response.code !== 0) {
|
||||||
ElMessage.error(response.message || "报告发布失败");
|
ElMessage.error(response.message || "报告发布失败");
|
||||||
return;
|
return;
|
||||||
@@ -427,6 +460,12 @@ watch(
|
|||||||
<OrderStatusTag :status="row.report_status_text" />
|
<OrderStatusTag :status="row.report_status_text" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="验真吊牌" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<OrderStatusTag v-if="row.report_type !== 'inspection'" :status="row.material_tag_bound ? '已绑定' : '未绑定'" />
|
||||||
|
<span v-else class="detail-label">不适用</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="institution_name" label="出具机构" min-width="160" />
|
<el-table-column prop="institution_name" label="出具机构" min-width="160" />
|
||||||
<el-table-column prop="publish_time" label="发布时间" min-width="170" />
|
<el-table-column prop="publish_time" label="发布时间" min-width="170" />
|
||||||
<el-table-column label="操作" fixed="right" width="220">
|
<el-table-column label="操作" fixed="right" width="220">
|
||||||
@@ -464,7 +503,12 @@ watch(
|
|||||||
v-if="canPublishCurrentReport"
|
v-if="canPublishCurrentReport"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="publishingId === detail.report_header.id"
|
:loading="publishingId === detail.report_header.id"
|
||||||
@click="publishReport({ id: detail.report_header.id, report_status: detail.report_header.report_status })"
|
@click="publishReport({
|
||||||
|
id: detail.report_header.id,
|
||||||
|
report_status: detail.report_header.report_status,
|
||||||
|
report_type: detail.report_header.report_type,
|
||||||
|
material_tag_bound: Boolean(detail.material_tag),
|
||||||
|
})"
|
||||||
>
|
>
|
||||||
发布报告
|
发布报告
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -496,6 +540,32 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="detail-card__title">验真吊牌</div>
|
||||||
|
<template v-if="detail.report_header.report_type === 'inspection'">
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-value">补录检查单不需要绑定验真吊牌</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="detail.material_tag">
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">二维码链接</div>
|
||||||
|
<div class="detail-value" style="word-break: break-all;">{{ detail.material_tag.qr_url }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">验真编码</div>
|
||||||
|
<div class="detail-value">{{ detail.material_tag.verify_code }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">绑定时间</div>
|
||||||
|
<div class="detail-value">{{ detail.material_tag.bound_at || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="detail-card__desc">
|
||||||
|
<div class="detail-value">未绑定,发布前需要扫描验真吊牌。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<div class="detail-card__title">商品信息</div>
|
<div class="detail-card__title">商品信息</div>
|
||||||
<div class="detail-card__desc">
|
<div class="detail-card__desc">
|
||||||
@@ -652,7 +722,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="detail.report_header.service_provider !== 'zhongjian'" class="detail-card" style="grid-column: 1 / -1">
|
<div class="detail-card" style="grid-column: 1 / -1">
|
||||||
<div class="detail-card__title">扫码与公开链接</div>
|
<div class="detail-card__title">扫码与公开链接</div>
|
||||||
<div style="display: grid; grid-template-columns: 220px 1fr; gap: 24px; align-items: start;">
|
<div style="display: grid; grid-template-columns: 220px 1fr; gap: 24px; align-items: start;">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineComponent, h, nextTick, ref, type PropType } from "vue";
|
import { computed, defineComponent, h, nextTick, ref, type PropType } from "vue";
|
||||||
import { ElMessage, type InputInstance } from "element-plus";
|
import { ElMessage, type InputInstance } from "element-plus";
|
||||||
import { adminApi, type AdminWarehouseWorkbenchContext } from "../../api/admin";
|
import {
|
||||||
|
adminApi,
|
||||||
|
type AdminFileAsset,
|
||||||
|
type AdminReportDetail,
|
||||||
|
type AdminWarehouseWorkbenchContext,
|
||||||
|
} from "../../api/admin";
|
||||||
import OrderStatusTag from "../../components/OrderStatusTag.vue";
|
import OrderStatusTag from "../../components/OrderStatusTag.vue";
|
||||||
|
|
||||||
const activeMode = ref<"inbound" | "zhongjian" | "return">("inbound");
|
const activeMode = ref<"inbound" | "zhongjian" | "return">("inbound");
|
||||||
@@ -15,17 +20,31 @@ const returnTagNo = ref("");
|
|||||||
const returnMaterialQr = ref("");
|
const returnMaterialQr = ref("");
|
||||||
const returnExpressCompany = ref("");
|
const returnExpressCompany = ref("");
|
||||||
const returnTrackingNo = ref("");
|
const returnTrackingNo = ref("");
|
||||||
|
const returnPackingAttachments = ref<AdminFileAsset[]>([]);
|
||||||
|
|
||||||
const inboundContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
const inboundContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||||
const zhongjianContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
const zhongjianContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||||
const returnContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
const returnContext = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||||
|
const returnReviewReport = ref<AdminReportDetail | null>(null);
|
||||||
|
|
||||||
const inboundTagInputRef = ref<InputInstance | null>(null);
|
const inboundTagInputRef = ref<InputInstance | null>(null);
|
||||||
const returnMaterialInputRef = ref<InputInstance | null>(null);
|
const returnMaterialInputRef = ref<InputInstance | null>(null);
|
||||||
const returnTrackingInputRef = ref<InputInstance | null>(null);
|
const returnTrackingInputRef = ref<InputInstance | null>(null);
|
||||||
|
|
||||||
|
const returnReviewDrawerVisible = ref(false);
|
||||||
|
const returnReviewLoading = ref(false);
|
||||||
|
const returnConfirmLoading = ref(false);
|
||||||
|
const returnPackingUploading = ref(false);
|
||||||
|
|
||||||
const currentReturnIsZhongjian = computed(() => returnContext.value?.order_info.service_provider === "zhongjian");
|
const currentReturnIsZhongjian = computed(() => returnContext.value?.order_info.service_provider === "zhongjian");
|
||||||
const returnConfirmed = computed(() => Boolean(returnContext.value?.transfer_flow?.return_confirmed_at));
|
const returnConfirmed = computed(() => Boolean(returnContext.value?.transfer_flow?.return_confirmed_at));
|
||||||
|
const returnMaterialMatched = computed(() => Boolean(returnContext.value?.return_verification?.verified));
|
||||||
|
const returnReviewReportId = computed(() => Number(returnContext.value?.report_info?.id || returnContext.value?.return_verification?.report_id || 0));
|
||||||
|
const returnReportActionText = computed(() => {
|
||||||
|
if (returnConfirmed.value) return "报告已确认";
|
||||||
|
if (currentReturnIsZhongjian.value || returnMaterialMatched.value) return "核对报告";
|
||||||
|
return "匹配吊牌并核对报告";
|
||||||
|
});
|
||||||
|
|
||||||
const OrderContextCard = defineComponent({
|
const OrderContextCard = defineComponent({
|
||||||
name: "OrderContextCard",
|
name: "OrderContextCard",
|
||||||
@@ -37,6 +56,23 @@ const OrderContextCard = defineComponent({
|
|||||||
},
|
},
|
||||||
emits: ["open-file"],
|
emits: ["open-file"],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
|
const renderFileButtons = (title: string, files?: AdminFileAsset[]) => {
|
||||||
|
if (!files?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return h("div", { class: "flow-log-files" }, [
|
||||||
|
h("div", { class: "flow-log-files__title" }, title),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "file-list" },
|
||||||
|
files.map((file) =>
|
||||||
|
h("button", { class: "file-button", type: "button", onClick: () => emit("open-file", file.file_url) }, file.name || file.file_url),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (!props.context) {
|
if (!props.context) {
|
||||||
return h("div", { class: "detail-card empty-context" }, "等待扫码识别订单");
|
return h("div", { class: "detail-card empty-context" }, "等待扫码识别订单");
|
||||||
@@ -97,6 +133,8 @@ const OrderContextCard = defineComponent({
|
|||||||
]),
|
]),
|
||||||
h("div", { class: "flow-log-item__meta" }, `${log.operator_name || "系统"} / ${log.after_stage || "-"} / ${log.after_location || "-"}`),
|
h("div", { class: "flow-log-item__meta" }, `${log.operator_name || "系统"} / ${log.after_stage || "-"} / ${log.after_location || "-"}`),
|
||||||
log.remark ? h("div", { class: "flow-log-item__remark" }, log.remark) : null,
|
log.remark ? h("div", { class: "flow-log-item__remark" }, log.remark) : null,
|
||||||
|
renderFileButtons("入库附件", log.inbound_attachments),
|
||||||
|
renderFileButtons("装箱附件", log.packing_attachments),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -114,7 +152,7 @@ function resetMode(mode: typeof activeMode.value) {
|
|||||||
async function lookupInbound() {
|
async function lookupInbound() {
|
||||||
const trackingNo = inboundTrackingNo.value.trim();
|
const trackingNo = inboundTrackingNo.value.trim();
|
||||||
if (!trackingNo) {
|
if (!trackingNo) {
|
||||||
ElMessage.warning("请扫描寄入运单号");
|
ElMessage.warning("请扫描快递单号或输入鉴定订单号");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -126,7 +164,7 @@ async function lookupInbound() {
|
|||||||
inboundTagInputRef.value?.focus();
|
inboundTagInputRef.value?.focus();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
inboundContext.value = null;
|
inboundContext.value = null;
|
||||||
ElMessage.error(error?.message || "未匹配到订单");
|
ElMessage.error(error?.message || "未匹配到待入库订单");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -144,7 +182,7 @@ async function receiveInbound() {
|
|||||||
actionLoading.value = true;
|
actionLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.receiveWarehouseInbound({
|
const response = await adminApi.receiveWarehouseInbound({
|
||||||
tracking_no: inboundTrackingNo.value.trim(),
|
inbound_no: inboundTrackingNo.value.trim(),
|
||||||
internal_tag_no: inboundTagNo.value.trim(),
|
internal_tag_no: inboundTagNo.value.trim(),
|
||||||
});
|
});
|
||||||
inboundContext.value = response.data;
|
inboundContext.value = response.data;
|
||||||
@@ -205,13 +243,19 @@ async function lookupReturn() {
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
|
returnMaterialQr.value = "";
|
||||||
|
returnExpressCompany.value = "";
|
||||||
|
returnTrackingNo.value = "";
|
||||||
|
returnPackingAttachments.value = [];
|
||||||
|
returnReviewReport.value = null;
|
||||||
|
returnReviewDrawerVisible.value = false;
|
||||||
const response = await adminApi.lookupWarehouseReturn(returnTagNo.value.trim());
|
const response = await adminApi.lookupWarehouseReturn(returnTagNo.value.trim());
|
||||||
returnContext.value = response.data;
|
returnContext.value = response.data;
|
||||||
ElMessage.success("已打开待寄回订单");
|
ElMessage.success("已打开待寄回订单");
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (response.data.order_info.service_provider === "zhongjian") {
|
if (response.data.transfer_flow?.return_confirmed_at) {
|
||||||
returnTrackingInputRef.value?.focus();
|
returnTrackingInputRef.value?.focus();
|
||||||
} else {
|
} else if (response.data.order_info.service_provider !== "zhongjian") {
|
||||||
returnMaterialInputRef.value?.focus();
|
returnMaterialInputRef.value?.focus();
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -222,30 +266,119 @@ async function lookupReturn() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmReturnReport() {
|
async function openReturnReportReview() {
|
||||||
|
const reportId = returnReviewReportId.value;
|
||||||
|
if (!reportId) {
|
||||||
|
ElMessage.warning("未找到可核对的报告");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
returnReviewDrawerVisible.value = true;
|
||||||
|
returnReviewLoading.value = true;
|
||||||
|
returnReviewReport.value = null;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.getReportDetail(reportId);
|
||||||
|
if (response.code !== 0) {
|
||||||
|
ElMessage.error(response.message || "报告详情加载失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
returnReviewReport.value = response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error(error?.message || "报告详情加载失败");
|
||||||
|
} finally {
|
||||||
|
returnReviewLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReturnReportStep() {
|
||||||
if (!returnContext.value) {
|
if (!returnContext.value) {
|
||||||
await lookupReturn();
|
await lookupReturn();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (returnConfirmed.value) {
|
||||||
|
ElMessage.success("报告已确认,请填写回寄信息");
|
||||||
|
await nextTick();
|
||||||
|
returnTrackingInputRef.value?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentReturnIsZhongjian.value && !returnMaterialMatched.value && !returnMaterialQr.value.trim()) {
|
||||||
|
ElMessage.warning("请扫描或填写平台验真吊牌链接");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
actionLoading.value = true;
|
actionLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = currentReturnIsZhongjian.value
|
if (!currentReturnIsZhongjian.value && !returnMaterialMatched.value) {
|
||||||
? await adminApi.confirmWarehouseReturnZhongjian(returnTagNo.value.trim())
|
const response = await adminApi.verifyWarehouseReturnMaterialTag({
|
||||||
: await adminApi.verifyWarehouseReturnMaterialTag({
|
|
||||||
internal_tag_no: returnTagNo.value.trim(),
|
internal_tag_no: returnTagNo.value.trim(),
|
||||||
qr_input: returnMaterialQr.value.trim(),
|
qr_input: returnMaterialQr.value.trim(),
|
||||||
});
|
});
|
||||||
returnContext.value = response.data;
|
returnContext.value = response.data;
|
||||||
ElMessage.success(currentReturnIsZhongjian.value ? "中检报告已确认" : "验真吊牌已确认");
|
ElMessage.success(response.message || "验真吊牌匹配通过,请核对报告");
|
||||||
await nextTick();
|
}
|
||||||
returnTrackingInputRef.value?.focus();
|
await openReturnReportReview();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || "报告确认失败");
|
ElMessage.error(error?.message || "报告核对失败");
|
||||||
} finally {
|
} finally {
|
||||||
actionLoading.value = false;
|
actionLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmReturnReview() {
|
||||||
|
if (!returnReviewReport.value) {
|
||||||
|
ElMessage.warning("请先加载报告详情");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
returnConfirmLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.confirmWarehouseReturnReport({
|
||||||
|
internal_tag_no: returnTagNo.value.trim(),
|
||||||
|
report_id: returnReviewReport.value.report_header.id,
|
||||||
|
});
|
||||||
|
if (response.code !== 0) {
|
||||||
|
ElMessage.error(response.message || "报告确认失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
returnContext.value = response.data;
|
||||||
|
returnReviewDrawerVisible.value = false;
|
||||||
|
ElMessage.success(response.message || "报告已确认,可填写回寄运单");
|
||||||
|
await nextTick();
|
||||||
|
returnTrackingInputRef.value?.focus();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error(error?.message || "报告确认失败");
|
||||||
|
} finally {
|
||||||
|
returnConfirmLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadReturnPackingAttachment(options: { file: File }) {
|
||||||
|
returnPackingUploading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.uploadWarehouseReturnPackingFile(options.file);
|
||||||
|
if (response.code !== 0) {
|
||||||
|
ElMessage.error(response.message || "装箱附件上传失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
returnPackingAttachments.value.push(response.data);
|
||||||
|
ElMessage.success("装箱附件已上传");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
ElMessage.error(error?.message || "装箱附件上传失败");
|
||||||
|
} finally {
|
||||||
|
returnPackingUploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeReturnPackingAttachment(fileUrl: string) {
|
||||||
|
returnPackingAttachments.value = returnPackingAttachments.value.filter((item) => item.file_url !== fileUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileTypeText(file: AdminFileAsset) {
|
||||||
|
return file.file_type === "image" ? "图片" : file.file_type === "video" ? "视频" : "附件";
|
||||||
|
}
|
||||||
|
|
||||||
async function shipReturn() {
|
async function shipReturn() {
|
||||||
if (!returnContext.value) {
|
if (!returnContext.value) {
|
||||||
await lookupReturn();
|
await lookupReturn();
|
||||||
@@ -259,14 +392,20 @@ async function shipReturn() {
|
|||||||
ElMessage.warning("请填写回寄快递公司和运单号");
|
ElMessage.warning("请填写回寄快递公司和运单号");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (returnPackingUploading.value) {
|
||||||
|
ElMessage.warning("装箱附件上传中,请稍后提交");
|
||||||
|
return;
|
||||||
|
}
|
||||||
actionLoading.value = true;
|
actionLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.shipWarehouseReturn({
|
const response = await adminApi.shipWarehouseReturn({
|
||||||
internal_tag_no: returnTagNo.value.trim(),
|
internal_tag_no: returnTagNo.value.trim(),
|
||||||
express_company: returnExpressCompany.value.trim(),
|
express_company: returnExpressCompany.value.trim(),
|
||||||
tracking_no: returnTrackingNo.value.trim(),
|
tracking_no: returnTrackingNo.value.trim(),
|
||||||
|
packing_attachments: returnPackingAttachments.value,
|
||||||
});
|
});
|
||||||
returnContext.value = response.data;
|
returnContext.value = response.data;
|
||||||
|
returnPackingAttachments.value = [];
|
||||||
ElMessage.success("回寄运单已登记");
|
ElMessage.success("回寄运单已登记");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || "回寄失败");
|
ElMessage.error(error?.message || "回寄失败");
|
||||||
@@ -299,7 +438,7 @@ function openFile(url: string) {
|
|||||||
<el-card class="panel-card" shadow="never">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>入库扫描</template>
|
<template #header>入库扫描</template>
|
||||||
<div class="scan-stack">
|
<div class="scan-stack">
|
||||||
<el-input v-model="inboundTrackingNo" size="large" placeholder="扫描寄入快递运单号" clearable @keyup.enter="lookupInbound" />
|
<el-input v-model="inboundTrackingNo" size="large" placeholder="扫描快递单号 / 输入鉴定订单号" clearable @keyup.enter="lookupInbound" />
|
||||||
<el-input ref="inboundTagInputRef" v-model="inboundTagNo" size="large" placeholder="扫描内部流转挂牌" clearable @keyup.enter="receiveInbound" />
|
<el-input ref="inboundTagInputRef" v-model="inboundTagNo" size="large" placeholder="扫描内部流转挂牌" clearable @keyup.enter="receiveInbound" />
|
||||||
<div class="actions-row">
|
<div class="actions-row">
|
||||||
<el-button type="primary" :loading="loading" @click="lookupInbound">匹配订单</el-button>
|
<el-button type="primary" :loading="loading" @click="lookupInbound">匹配订单</el-button>
|
||||||
@@ -332,37 +471,215 @@ function openFile(url: string) {
|
|||||||
<div class="scan-stack">
|
<div class="scan-stack">
|
||||||
<el-input v-model="returnTagNo" size="large" placeholder="扫描内部流转码" clearable @keyup.enter="lookupReturn" />
|
<el-input v-model="returnTagNo" size="large" placeholder="扫描内部流转码" clearable @keyup.enter="lookupReturn" />
|
||||||
<el-input
|
<el-input
|
||||||
v-if="returnContext && !currentReturnIsZhongjian"
|
v-if="returnContext && !currentReturnIsZhongjian && !returnMaterialMatched && !returnConfirmed"
|
||||||
ref="returnMaterialInputRef"
|
ref="returnMaterialInputRef"
|
||||||
v-model="returnMaterialQr"
|
v-model="returnMaterialQr"
|
||||||
size="large"
|
size="large"
|
||||||
placeholder="扫描平台验真吊牌"
|
placeholder="扫描或填写平台验真吊牌链接"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="confirmReturnReport"
|
@keyup.enter="handleReturnReportStep"
|
||||||
/>
|
/>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="returnContext && currentReturnIsZhongjian"
|
v-if="returnContext && currentReturnIsZhongjian && !returnConfirmed"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
title="中检订单不扫描平台验真吊牌"
|
title="中检订单不扫描平台验真吊牌"
|
||||||
description="请核对中检报告编号和报告文件,确认无误后进入回寄物流填写。"
|
description="请打开报告详情,核对中检报告编号和报告文件,确认无误后填写回寄物流。"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="returnContext && returnMaterialMatched && !returnConfirmed"
|
||||||
|
type="success"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="验真吊牌已匹配当前订单报告"
|
||||||
|
description="请继续核对报告详情,确认无误后填写回寄物流。"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="returnContext && returnConfirmed"
|
||||||
|
type="success"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="报告已确认"
|
||||||
|
description="可填写回寄运单并上传打包装箱图片或视频。"
|
||||||
/>
|
/>
|
||||||
<div class="actions-row">
|
<div class="actions-row">
|
||||||
<el-button type="primary" :loading="loading" @click="lookupReturn">打开订单</el-button>
|
<el-button type="primary" :loading="loading" @click="lookupReturn">打开订单</el-button>
|
||||||
<el-button type="success" :loading="actionLoading" :disabled="!returnContext" @click="confirmReturnReport">
|
<el-button
|
||||||
{{ currentReturnIsZhongjian ? "报告已确认" : "验真吊牌确认" }}
|
v-if="returnContext && !returnConfirmed"
|
||||||
|
type="success"
|
||||||
|
:loading="actionLoading"
|
||||||
|
:disabled="!returnContext"
|
||||||
|
@click="handleReturnReportStep"
|
||||||
|
>
|
||||||
|
{{ returnReportActionText }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="returnContext" class="return-form">
|
<div v-if="returnContext && returnConfirmed" class="return-form">
|
||||||
<el-input v-model="returnExpressCompany" size="large" placeholder="回寄快递公司,例如:顺丰速运" />
|
<el-input v-model="returnExpressCompany" size="large" placeholder="回寄快递公司,例如:顺丰速运" />
|
||||||
<el-input ref="returnTrackingInputRef" v-model="returnTrackingNo" size="large" placeholder="扫描或输入回寄运单号" @keyup.enter="shipReturn" />
|
<el-input ref="returnTrackingInputRef" v-model="returnTrackingNo" size="large" placeholder="扫描或输入回寄运单号" @keyup.enter="shipReturn" />
|
||||||
<el-button type="primary" size="large" :loading="actionLoading" :disabled="!returnConfirmed" @click="shipReturn">提交寄回</el-button>
|
<div class="packing-upload">
|
||||||
|
<div class="packing-upload-head">
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="uploadReturnPackingAttachment"
|
||||||
|
:disabled="returnPackingUploading"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<el-button :loading="returnPackingUploading">上传装箱图片/视频</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<span class="packing-upload-hint">{{ returnPackingAttachments.length }} 个装箱附件</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="returnPackingAttachments.length" class="packing-file-list">
|
||||||
|
<div v-for="file in returnPackingAttachments" :key="file.file_url" class="packing-file-item">
|
||||||
|
<button class="file-button" type="button" @click="openFile(file.file_url)">
|
||||||
|
{{ file.name || file.file_url }}
|
||||||
|
</button>
|
||||||
|
<span class="packing-file-type">{{ fileTypeText(file) }}</span>
|
||||||
|
<el-button link type="danger" @click="removeReturnPackingAttachment(file.file_url)">移除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="actionLoading"
|
||||||
|
:disabled="returnPackingUploading || !returnExpressCompany.trim() || !returnTrackingNo.trim()"
|
||||||
|
@click="shipReturn"
|
||||||
|
>
|
||||||
|
提交寄回
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
<OrderContextCard :context="returnContext" @open-file="openFile" />
|
<OrderContextCard :context="returnContext" @open-file="openFile" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-drawer v-model="returnReviewDrawerVisible" size="58%" title="回寄前报告核对">
|
||||||
|
<div v-loading="returnReviewLoading" class="return-review">
|
||||||
|
<template v-if="returnReviewReport">
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="请核对报告编号、结论、附件和验真信息"
|
||||||
|
description="确认无误后点击确认寄回,系统才会允许填写回寄运单。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="return-review-grid">
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="detail-card__title">报告概览</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">报告编号</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.report_header.report_no }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">报告标题</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.report_header.report_title }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">报告状态</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.report_header.report_status_text }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">发布时间</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.report_header.publish_time || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="detail-card__title">商品与结论</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">商品名称</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.product_info.product_name || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">品类 / 品牌</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.product_info.category_name || "-" }} / {{ returnReviewReport.product_info.brand_name || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">鉴定结论</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.result_info.result_text || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">结论说明</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.result_info.result_desc || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="detail-card__title">验真信息</div>
|
||||||
|
<template v-if="returnReviewReport.report_header.service_provider === 'zhongjian'">
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">中检报告编号</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.report_header.zhongjian_report_no || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">报告录入人</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.report_header.report_entry_admin_name || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">验真状态</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.verify_info.verify_status || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">验真链接</div>
|
||||||
|
<div class="detail-value break-text">{{ returnReviewReport.verify_info.verify_url || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="detail-card__title">估值与评级</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">成色评级</div>
|
||||||
|
<div class="detail-value">{{ returnReviewReport.valuation_info.condition_grade || "-" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card__desc">
|
||||||
|
<div class="detail-label">估值区间</div>
|
||||||
|
<div class="detail-value">¥{{ returnReviewReport.valuation_info.valuation_min || 0 }} - ¥{{ returnReviewReport.valuation_info.valuation_max || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card return-review-files">
|
||||||
|
<div class="detail-card__title">报告附件</div>
|
||||||
|
<div v-if="returnReviewReport.evidence_attachments.length || returnReviewReport.zhongjian_report_files.length" class="file-list">
|
||||||
|
<button
|
||||||
|
v-for="file in returnReviewReport.evidence_attachments"
|
||||||
|
:key="`evidence-${file.file_url}`"
|
||||||
|
class="file-button"
|
||||||
|
type="button"
|
||||||
|
@click="openFile(file.file_url)"
|
||||||
|
>
|
||||||
|
{{ file.name || file.file_url }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="file in returnReviewReport.zhongjian_report_files"
|
||||||
|
:key="`zhongjian-${file.file_url}`"
|
||||||
|
class="file-button"
|
||||||
|
type="button"
|
||||||
|
@click="openFile(file.file_url)"
|
||||||
|
>
|
||||||
|
{{ file.name || file.file_url }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="detail-card__desc">
|
||||||
|
<div class="detail-value">暂无报告附件</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="return-review-actions">
|
||||||
|
<el-button @click="returnReviewDrawerVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="returnConfirmLoading" @click="confirmReturnReview">确认寄回</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-empty v-else description="暂无报告详情" />
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -397,6 +714,40 @@ function openFile(url: string) {
|
|||||||
border-top: 1px solid var(--admin-border);
|
border-top: 1px solid var(--admin-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.packing-upload {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--admin-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packing-upload-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packing-upload-hint,
|
||||||
|
.packing-file-type {
|
||||||
|
color: var(--admin-text-subtle);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packing-file-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.packing-file-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-context {
|
.empty-context {
|
||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -541,4 +892,42 @@ function openFile(url: string) {
|
|||||||
.flow-log-item__remark {
|
.flow-log-item__remark {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flow-log-files {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-log-files__title {
|
||||||
|
color: var(--admin-text-main);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return-review {
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return-review-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return-review-files {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return-review-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-text {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class AppraisalTasksController
|
|||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
$this->applyTaskScopeFilterRows($allRows, $request, $scope);
|
$this->applyTaskScopeFilterRows($allRows, $request, $scope);
|
||||||
|
$this->attachTransferFlowToRows($allRows);
|
||||||
|
|
||||||
$list = $this->buildGroupedTaskList($allRows, $reportMap);
|
$list = $this->buildGroupedTaskList($allRows, $reportMap);
|
||||||
$total = count($list);
|
$total = count($list);
|
||||||
@@ -166,6 +167,7 @@ class AppraisalTasksController
|
|||||||
|
|
||||||
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||||
$materialTag = $report ? (new MaterialTagService())->findBoundTagForReport((int)$report['id']) : null;
|
$materialTag = $report ? (new MaterialTagService())->findBoundTagForReport((int)$report['id']) : null;
|
||||||
|
$transferFlow = $this->latestTransferFlowForOrder((int)$task['order_id']);
|
||||||
$effectiveStatus = $this->effectiveTaskStatus($task, $report);
|
$effectiveStatus = $this->effectiveTaskStatus($task, $report);
|
||||||
if ($effectiveStatus !== $task['status']) {
|
if ($effectiveStatus !== $task['status']) {
|
||||||
Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update([
|
Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update([
|
||||||
@@ -232,6 +234,7 @@ class AppraisalTasksController
|
|||||||
->order('t.id', 'asc')
|
->order('t.id', 'asc')
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
$this->attachTransferFlowToRows($stageTaskRows);
|
||||||
|
|
||||||
$stageTasks = array_map(function (array $item) use ($id, $stageReportMap) {
|
$stageTasks = array_map(function (array $item) use ($id, $stageReportMap) {
|
||||||
$row = $this->normalizeTaskListRow($item, $stageReportMap[(int)$item['order_id']] ?? null);
|
$row = $this->normalizeTaskListRow($item, $stageReportMap[(int)$item['order_id']] ?? null);
|
||||||
@@ -305,6 +308,7 @@ class AppraisalTasksController
|
|||||||
'submitted_at' => $task['submitted_at'],
|
'submitted_at' => $task['submitted_at'],
|
||||||
'sla_deadline' => $task['sla_deadline'],
|
'sla_deadline' => $task['sla_deadline'],
|
||||||
'is_overtime' => (bool)$task['is_overtime'],
|
'is_overtime' => (bool)$task['is_overtime'],
|
||||||
|
'internal_tag_no' => (string)($transferFlow['internal_tag_no'] ?? ''),
|
||||||
],
|
],
|
||||||
'report_summary' => $report ? [
|
'report_summary' => $report ? [
|
||||||
'id' => (int)$report['id'],
|
'id' => (int)$report['id'],
|
||||||
@@ -373,10 +377,6 @@ class AppraisalTasksController
|
|||||||
if (!$task) {
|
if (!$task) {
|
||||||
return api_error('任务不存在', 404);
|
return api_error('任务不存在', 404);
|
||||||
}
|
}
|
||||||
if (($task['service_provider'] ?? '') === 'zhongjian') {
|
|
||||||
return api_error('中检订单不使用平台验真吊牌', 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$operatorGuard = $this->guardTaskOperator($request, $task);
|
$operatorGuard = $this->guardTaskOperator($request, $task);
|
||||||
if ($operatorGuard['error']) {
|
if ($operatorGuard['error']) {
|
||||||
return $operatorGuard['error'];
|
return $operatorGuard['error'];
|
||||||
@@ -426,23 +426,25 @@ class AppraisalTasksController
|
|||||||
if (!$task) {
|
if (!$task) {
|
||||||
return api_error('任务不存在', 404);
|
return api_error('任务不存在', 404);
|
||||||
}
|
}
|
||||||
if (($task['service_provider'] ?? '') === 'zhongjian') {
|
Db::startTrans();
|
||||||
return api_error('中检订单不使用平台验真吊牌', 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
|
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
|
||||||
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||||
if (!$report) {
|
if (!$report) {
|
||||||
|
Db::rollback();
|
||||||
return api_error('请先提交鉴定结论生成报告草稿', 422);
|
return api_error('请先提交鉴定结论生成报告草稿', 422);
|
||||||
}
|
}
|
||||||
$publish = $this->publishReportRecord($report, $request);
|
$publish = $this->publishReportRecord($report, $request, false);
|
||||||
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
|
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
|
||||||
|
Db::commit();
|
||||||
} catch (\InvalidArgumentException $e) {
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
Db::rollback();
|
||||||
return api_error($e->getMessage(), 422);
|
return api_error($e->getMessage(), 422);
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
|
Db::rollback();
|
||||||
return api_error($e->getMessage(), $e->getCode() ?: 404);
|
return api_error($e->getMessage(), $e->getCode() ?: 404);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
return api_error('验真吊牌绑定或报告发布失败', 500, ['detail' => $e->getMessage()]);
|
return api_error('验真吊牌绑定或报告发布失败', 500, ['detail' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +459,7 @@ class AppraisalTasksController
|
|||||||
{
|
{
|
||||||
$id = (int)$request->input('id', 0);
|
$id = (int)$request->input('id', 0);
|
||||||
$reportNo = trim((string)$request->input('zhongjian_report_no', ''));
|
$reportNo = trim((string)$request->input('zhongjian_report_no', ''));
|
||||||
|
$qrInput = trim((string)$request->input('qr_input', ''));
|
||||||
$files = $this->evidenceService()->normalize($request->input('report_files', []), $request, true);
|
$files = $this->evidenceService()->normalize($request->input('report_files', []), $request, true);
|
||||||
if ($id <= 0) {
|
if ($id <= 0) {
|
||||||
return api_error('任务 ID 不能为空', 422);
|
return api_error('任务 ID 不能为空', 422);
|
||||||
@@ -467,6 +470,9 @@ class AppraisalTasksController
|
|||||||
if (!$files) {
|
if (!$files) {
|
||||||
return api_error('请至少上传 1 个中检报告文件', 422);
|
return api_error('请至少上传 1 个中检报告文件', 422);
|
||||||
}
|
}
|
||||||
|
if ($qrInput === '') {
|
||||||
|
return api_error('请扫描验真吊牌二维码', 422);
|
||||||
|
}
|
||||||
|
|
||||||
$task = Db::name('appraisal_tasks')->where('id', $id)->find();
|
$task = Db::name('appraisal_tasks')->where('id', $id)->find();
|
||||||
if (!$task) {
|
if (!$task) {
|
||||||
@@ -475,6 +481,20 @@ class AppraisalTasksController
|
|||||||
if (($task['service_provider'] ?? '') !== 'zhongjian') {
|
if (($task['service_provider'] ?? '') !== 'zhongjian') {
|
||||||
return api_error('非中检订单不能录入中检报告', 422);
|
return api_error('非中检订单不能录入中检报告', 422);
|
||||||
}
|
}
|
||||||
|
$order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: [];
|
||||||
|
$task['order_status'] = $order['order_status'] ?? '';
|
||||||
|
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||||
|
$effectiveStatus = $this->effectiveTaskStatus($task, $report);
|
||||||
|
if ($effectiveStatus !== $task['status']) {
|
||||||
|
Db::name('appraisal_tasks')->where('id', $id)->update([
|
||||||
|
'status' => $effectiveStatus,
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
$task['status'] = $effectiveStatus;
|
||||||
|
}
|
||||||
|
if (in_array($effectiveStatus, ['submitted', 'completed'], true)) {
|
||||||
|
return api_error('当前任务已流转完成,不能再录入中检报告', 422);
|
||||||
|
}
|
||||||
|
|
||||||
$operatorGuard = $this->guardTaskOperator($request, $task);
|
$operatorGuard = $this->guardTaskOperator($request, $task);
|
||||||
if ($operatorGuard['error']) {
|
if ($operatorGuard['error']) {
|
||||||
@@ -565,22 +585,25 @@ class AppraisalTasksController
|
|||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Db::commit();
|
|
||||||
|
|
||||||
$freshReport = $this->findLatestAppraisalReport((int)$task['order_id']);
|
$freshReport = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||||
$publish = $this->publishReportRecord($freshReport, $request);
|
if (!$freshReport) {
|
||||||
|
Db::rollback();
|
||||||
|
return api_error('中检报告草稿生成失败', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
|
||||||
|
$publish = $this->publishReportRecord($freshReport, $request, false);
|
||||||
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
|
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
|
||||||
|
|
||||||
|
Db::commit();
|
||||||
|
|
||||||
return api_success([
|
return api_success([
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
|
'material_tag' => $tag,
|
||||||
'report' => $publish,
|
'report' => $publish,
|
||||||
], '中检报告已录入并发布');
|
], '验真吊牌已绑定,报告已发布');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
try {
|
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
} catch (\Throwable $rollbackError) {
|
|
||||||
// Transaction may already be committed before publishing.
|
|
||||||
}
|
|
||||||
return api_error('中检报告录入失败', 500, ['detail' => $e->getMessage()]);
|
return api_error('中检报告录入失败', 500, ['detail' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -640,6 +663,7 @@ class AppraisalTasksController
|
|||||||
{
|
{
|
||||||
$id = (int)$request->input('id', 0);
|
$id = (int)$request->input('id', 0);
|
||||||
$action = trim((string)$request->input('action', 'save'));
|
$action = trim((string)$request->input('action', 'save'));
|
||||||
|
$qrInput = trim((string)$request->input('qr_input', ''));
|
||||||
|
|
||||||
if (!$id) {
|
if (!$id) {
|
||||||
return api_error('任务 ID 不能为空', 422);
|
return api_error('任务 ID 不能为空', 422);
|
||||||
@@ -675,6 +699,9 @@ class AppraisalTasksController
|
|||||||
if ($action !== 'save' && $resultText === '') {
|
if ($action !== 'save' && $resultText === '') {
|
||||||
return api_error('鉴定结论不能为空', 422);
|
return api_error('鉴定结论不能为空', 422);
|
||||||
}
|
}
|
||||||
|
if ($action !== 'save' && $qrInput === '') {
|
||||||
|
return api_error('请扫描验真吊牌二维码', 422);
|
||||||
|
}
|
||||||
$productInput = $request->input('product_info', null);
|
$productInput = $request->input('product_info', null);
|
||||||
$productPayload = is_array($productInput) ? $this->normalizeProductInput($productInput) : null;
|
$productPayload = is_array($productInput) ? $this->normalizeProductInput($productInput) : null;
|
||||||
$attachments = $this->evidenceService()->normalize($request->input('attachments', []), $request, true);
|
$attachments = $this->evidenceService()->normalize($request->input('attachments', []), $request, true);
|
||||||
@@ -774,6 +801,14 @@ class AppraisalTasksController
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->createOrUpdateReportDraft((int)$task['order_id'], $task, $payload, $now);
|
$this->createOrUpdateReportDraft((int)$task['order_id'], $task, $payload, $now);
|
||||||
|
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||||
|
if (!$report) {
|
||||||
|
Db::rollback();
|
||||||
|
return api_error('报告草稿生成失败', 500);
|
||||||
|
}
|
||||||
|
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
|
||||||
|
$publish = $this->publishReportRecord($report, $request, false);
|
||||||
|
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
|
||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
(new EnterpriseWebhookService())->recordOrderEvent((int)$task['order_id'], 'appraisal_finished', [
|
(new EnterpriseWebhookService())->recordOrderEvent((int)$task['order_id'], 'appraisal_finished', [
|
||||||
@@ -781,7 +816,11 @@ class AppraisalTasksController
|
|||||||
'task_stage' => $task['task_stage'],
|
'task_stage' => $task['task_stage'],
|
||||||
'finished_at' => $now,
|
'finished_at' => $now,
|
||||||
]);
|
]);
|
||||||
return api_success(['id' => $id], '鉴定已完成,报告草稿已生成');
|
return api_success([
|
||||||
|
'id' => $id,
|
||||||
|
'material_tag' => $tag,
|
||||||
|
'report' => $publish,
|
||||||
|
], '验真吊牌已绑定,报告已发布');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
return api_error('结论保存失败', 500, [
|
return api_error('结论保存失败', 500, [
|
||||||
@@ -976,6 +1015,15 @@ class AppraisalTasksController
|
|||||||
|
|
||||||
public function uploadEvidenceFile(Request $request)
|
public function uploadEvidenceFile(Request $request)
|
||||||
{
|
{
|
||||||
|
$taskId = (int)$request->input('task_id', 0);
|
||||||
|
if ($taskId <= 0) {
|
||||||
|
return api_error('任务 ID 不能为空', 422);
|
||||||
|
}
|
||||||
|
$editableGuard = $this->guardTaskEditable($taskId, '当前任务已流转完成,不能再上传附件');
|
||||||
|
if ($editableGuard) {
|
||||||
|
return $editableGuard;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$asset = $this->evidenceService()->upload($request);
|
$asset = $this->evidenceService()->upload($request);
|
||||||
return api_success($asset);
|
return api_success($asset);
|
||||||
@@ -986,6 +1034,15 @@ class AppraisalTasksController
|
|||||||
|
|
||||||
public function deleteEvidenceFile(Request $request)
|
public function deleteEvidenceFile(Request $request)
|
||||||
{
|
{
|
||||||
|
$taskId = (int)$request->input('task_id', 0);
|
||||||
|
if ($taskId <= 0) {
|
||||||
|
return api_error('任务 ID 不能为空', 422);
|
||||||
|
}
|
||||||
|
$editableGuard = $this->guardTaskEditable($taskId, '当前任务已流转完成,不能再删除附件');
|
||||||
|
if ($editableGuard) {
|
||||||
|
return $editableGuard;
|
||||||
|
}
|
||||||
|
|
||||||
$fileUrl = trim((string)$request->input('file_url', ''));
|
$fileUrl = trim((string)$request->input('file_url', ''));
|
||||||
if ($fileUrl === '') {
|
if ($fileUrl === '') {
|
||||||
return api_error('文件地址不能为空', 422);
|
return api_error('文件地址不能为空', 422);
|
||||||
@@ -1093,9 +1150,86 @@ class AppraisalTasksController
|
|||||||
'sla_deadline' => $item['sla_deadline'],
|
'sla_deadline' => $item['sla_deadline'],
|
||||||
'is_overtime' => (bool)$item['is_overtime'],
|
'is_overtime' => (bool)$item['is_overtime'],
|
||||||
'display_status' => $item['display_status'],
|
'display_status' => $item['display_status'],
|
||||||
|
'internal_tag_no' => (string)($item['internal_tag_no'] ?? ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function attachTransferFlowToRows(array &$rows): void
|
||||||
|
{
|
||||||
|
$orderIds = array_values(array_unique(array_filter(array_map(fn (array $item) => (int)($item['order_id'] ?? 0), $rows))));
|
||||||
|
if (!$orderIds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flowMap = $this->latestTransferFlowMap($orderIds);
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$orderId = (int)($row['order_id'] ?? 0);
|
||||||
|
$row['internal_tag_no'] = (string)($flowMap[$orderId]['internal_tag_no'] ?? '');
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestTransferFlowForOrder(int $orderId): ?array
|
||||||
|
{
|
||||||
|
if ($orderId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Db::name('order_transfer_flows')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestTransferFlowMap(array $orderIds): array
|
||||||
|
{
|
||||||
|
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
|
||||||
|
if (!$orderIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = Db::name('order_transfer_flows')
|
||||||
|
->whereIn('order_id', $orderIds)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$orderId = (int)($row['order_id'] ?? 0);
|
||||||
|
if ($orderId > 0 && !isset($map[$orderId])) {
|
||||||
|
$map[$orderId] = [
|
||||||
|
'internal_tag_no' => (string)($row['internal_tag_no'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function guardTaskEditable(int $taskId, string $message)
|
||||||
|
{
|
||||||
|
$task = Db::name('appraisal_tasks')->where('id', $taskId)->find();
|
||||||
|
if (!$task) {
|
||||||
|
return api_error('任务不存在', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: [];
|
||||||
|
$task['order_status'] = $order['order_status'] ?? '';
|
||||||
|
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
|
||||||
|
$effectiveStatus = $this->effectiveTaskStatus($task, $report);
|
||||||
|
if ($effectiveStatus !== (string)$task['status']) {
|
||||||
|
Db::name('appraisal_tasks')->where('id', $taskId)->update([
|
||||||
|
'status' => $effectiveStatus,
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($effectiveStatus, ['submitted', 'completed'], true)
|
||||||
|
? api_error($message, 422)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private function formatResultInfo(array $task, ?Request $request = null): array
|
private function formatResultInfo(array $task, ?Request $request = null): array
|
||||||
{
|
{
|
||||||
$resultId = 0;
|
$resultId = 0;
|
||||||
@@ -1866,7 +2000,7 @@ class AppraisalTasksController
|
|||||||
return $admin;
|
return $admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function publishReportRecord(array $report, Request $request): array
|
private function publishReportRecord(array $report, Request $request, bool $wrapTransaction = true): array
|
||||||
{
|
{
|
||||||
if (!$report) {
|
if (!$report) {
|
||||||
throw new \RuntimeException('报告不存在', 404);
|
throw new \RuntimeException('报告不存在', 404);
|
||||||
@@ -1878,10 +2012,11 @@ class AppraisalTasksController
|
|||||||
$operatorId = (int)$request->header('x-admin-id', 0);
|
$operatorId = (int)$request->header('x-admin-id', 0);
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
$effectivePublishTime = $report['publish_time'] ?: $now;
|
$effectivePublishTime = $report['publish_time'] ?: $now;
|
||||||
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
|
|
||||||
$verify = [];
|
$verify = [];
|
||||||
|
|
||||||
|
if ($wrapTransaction) {
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (($report['report_status'] ?? '') !== 'published') {
|
if (($report['report_status'] ?? '') !== 'published') {
|
||||||
Db::name('reports')->where('id', (int)$report['id'])->update([
|
Db::name('reports')->where('id', (int)$report['id'])->update([
|
||||||
@@ -1893,9 +2028,7 @@ class AppraisalTasksController
|
|||||||
$report['publish_time'] = $effectivePublishTime;
|
$report['publish_time'] = $effectivePublishTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($usesPlatformVerify) {
|
|
||||||
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
||||||
}
|
|
||||||
|
|
||||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
||||||
Db::name('orders')->where('id', (int)$report['order_id'])->update([
|
Db::name('orders')->where('id', (int)$report['order_id'])->update([
|
||||||
@@ -1933,15 +2066,19 @@ class AppraisalTasksController
|
|||||||
'report_title' => (string)$report['report_title'],
|
'report_title' => (string)$report['report_title'],
|
||||||
'product_name' => $product['product_name'] ?? '',
|
'product_name' => $product['product_name'] ?? '',
|
||||||
'publish_time' => $effectivePublishTime,
|
'publish_time' => $effectivePublishTime,
|
||||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||||
'fallback_title' => '报告已出具',
|
'fallback_title' => '报告已出具',
|
||||||
'fallback_content' => '您的正式报告已生成,可前往报告中心查看。',
|
'fallback_content' => '您的正式报告已生成,可前往报告中心查看。',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($wrapTransaction) {
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
if ($wrapTransaction) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
|
}
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1951,8 +2088,8 @@ class AppraisalTasksController
|
|||||||
'report_no' => (string)$report['report_no'],
|
'report_no' => (string)$report['report_no'],
|
||||||
'report_title' => (string)$report['report_title'],
|
'report_title' => (string)$report['report_title'],
|
||||||
'publish_time' => $effectivePublishTime,
|
'publish_time' => $effectivePublishTime,
|
||||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||||
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
|
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1960,8 +2097,8 @@ class AppraisalTasksController
|
|||||||
'id' => (int)$report['id'],
|
'id' => (int)$report['id'],
|
||||||
'report_status' => 'published',
|
'report_status' => 'published',
|
||||||
'publish_time' => $effectivePublishTime,
|
'publish_time' => $effectivePublishTime,
|
||||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||||
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
|
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace app\controller\admin;
|
namespace app\controller\admin;
|
||||||
|
|
||||||
|
use app\support\AppraisalEvidenceService;
|
||||||
use app\support\MessageDispatcher;
|
use app\support\MessageDispatcher;
|
||||||
use app\support\EnterpriseWebhookService;
|
use app\support\EnterpriseWebhookService;
|
||||||
use app\support\WarehouseService;
|
use app\support\WarehouseService;
|
||||||
@@ -10,6 +11,8 @@ use support\think\Db;
|
|||||||
|
|
||||||
class OrdersController
|
class OrdersController
|
||||||
{
|
{
|
||||||
|
private const MANUAL_ENTRY_SOURCE = 'manual_entry';
|
||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$keyword = trim((string)$request->input('keyword', ''));
|
$keyword = trim((string)$request->input('keyword', ''));
|
||||||
@@ -56,6 +59,7 @@ class OrdersController
|
|||||||
|
|
||||||
$warehouseStatusFilters = [
|
$warehouseStatusFilters = [
|
||||||
'warehouse_active',
|
'warehouse_active',
|
||||||
|
'warehouse_pending_inbound',
|
||||||
'warehouse_in_transit',
|
'warehouse_in_transit',
|
||||||
'warehouse_received',
|
'warehouse_received',
|
||||||
'warehouse_pending_return',
|
'warehouse_pending_return',
|
||||||
@@ -77,6 +81,9 @@ class OrdersController
|
|||||||
];
|
];
|
||||||
if ($status === 'warehouse_in_transit') {
|
if ($status === 'warehouse_in_transit') {
|
||||||
$query->where('o.order_status', 'pending_shipping');
|
$query->where('o.order_status', 'pending_shipping');
|
||||||
|
} elseif ($status === 'warehouse_pending_inbound') {
|
||||||
|
$query->where('o.order_status', 'pending_shipping')
|
||||||
|
->where('o.source_channel', self::MANUAL_ENTRY_SOURCE);
|
||||||
} elseif ($status === 'warehouse_received') {
|
} elseif ($status === 'warehouse_received') {
|
||||||
$query->whereIn('o.order_status', array_values(array_diff($warehouseActiveStatuses, ['pending_shipping', 'report_published'])));
|
$query->whereIn('o.order_status', array_values(array_diff($warehouseActiveStatuses, ['pending_shipping', 'report_published'])));
|
||||||
} elseif ($status === 'warehouse_pending_return') {
|
} elseif ($status === 'warehouse_pending_return') {
|
||||||
@@ -99,8 +106,9 @@ class OrdersController
|
|||||||
$orderIds = array_map('intval', array_column($rows, 'id'));
|
$orderIds = array_map('intval', array_column($rows, 'id'));
|
||||||
$sendTrackingMap = $this->latestLogisticsMap($orderIds, 'send_to_center');
|
$sendTrackingMap = $this->latestLogisticsMap($orderIds, 'send_to_center');
|
||||||
$returnTrackingMap = $this->latestLogisticsMap($orderIds, 'return_to_user');
|
$returnTrackingMap = $this->latestLogisticsMap($orderIds, 'return_to_user');
|
||||||
|
$transferFlowMap = $this->latestTransferFlowMap($orderIds);
|
||||||
|
|
||||||
$list = array_map(function (array $item) use ($sendTrackingMap, $returnTrackingMap) {
|
$list = array_map(function (array $item) use ($sendTrackingMap, $returnTrackingMap, $transferFlowMap) {
|
||||||
$orderId = (int)$item['id'];
|
$orderId = (int)$item['id'];
|
||||||
$sendTrackingNo = $sendTrackingMap[$orderId]['tracking_no'] ?? '';
|
$sendTrackingNo = $sendTrackingMap[$orderId]['tracking_no'] ?? '';
|
||||||
$sendTrackingStatus = $sendTrackingMap[$orderId]['tracking_status'] ?? '';
|
$sendTrackingStatus = $sendTrackingMap[$orderId]['tracking_status'] ?? '';
|
||||||
@@ -108,7 +116,8 @@ class OrdersController
|
|||||||
(string)$item['order_status'],
|
(string)$item['order_status'],
|
||||||
$sendTrackingNo,
|
$sendTrackingNo,
|
||||||
$sendTrackingStatus,
|
$sendTrackingStatus,
|
||||||
(string)($item['display_status'] ?? '')
|
(string)($item['display_status'] ?? ''),
|
||||||
|
(string)($item['source_channel'] ?? '')
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -130,6 +139,7 @@ class OrdersController
|
|||||||
$returnTrackingMap[$orderId]['tracking_no'] ?? '',
|
$returnTrackingMap[$orderId]['tracking_no'] ?? '',
|
||||||
$returnTrackingMap[$orderId]['tracking_status'] ?? '',
|
$returnTrackingMap[$orderId]['tracking_status'] ?? '',
|
||||||
),
|
),
|
||||||
|
'internal_tag_no' => $transferFlowMap[$orderId]['internal_tag_no'] ?? '',
|
||||||
'warehouse_bucket' => $warehouseBucket,
|
'warehouse_bucket' => $warehouseBucket,
|
||||||
'warehouse_bucket_text' => $this->warehouseOrderBucketText($warehouseBucket),
|
'warehouse_bucket_text' => $this->warehouseOrderBucketText($warehouseBucket),
|
||||||
'estimated_finish_time' => $item['estimated_finish_time'],
|
'estimated_finish_time' => $item['estimated_finish_time'],
|
||||||
@@ -154,6 +164,7 @@ class OrdersController
|
|||||||
$list = array_values(array_filter($list, function (array $item) use ($status) {
|
$list = array_values(array_filter($list, function (array $item) use ($status) {
|
||||||
if ($status === 'warehouse_active') {
|
if ($status === 'warehouse_active') {
|
||||||
return in_array($item['warehouse_bucket'], [
|
return in_array($item['warehouse_bucket'], [
|
||||||
|
'warehouse_pending_inbound',
|
||||||
'warehouse_in_transit',
|
'warehouse_in_transit',
|
||||||
'warehouse_received',
|
'warehouse_received',
|
||||||
'warehouse_pending_return',
|
'warehouse_pending_return',
|
||||||
@@ -206,6 +217,10 @@ class OrdersController
|
|||||||
->where('logistics_type', 'return_to_user')
|
->where('logistics_type', 'return_to_user')
|
||||||
->order('id', 'desc')
|
->order('id', 'desc')
|
||||||
->find();
|
->find();
|
||||||
|
$transferFlow = Db::name('order_transfer_flows')
|
||||||
|
->where('order_id', $id)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
$timeline = Db::name('order_timelines')
|
$timeline = Db::name('order_timelines')
|
||||||
->where('order_id', $id)
|
->where('order_id', $id)
|
||||||
->order('occurred_at', 'asc')
|
->order('occurred_at', 'asc')
|
||||||
@@ -268,6 +283,7 @@ class OrdersController
|
|||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
}
|
}
|
||||||
|
$inboundAttachments = $this->inboundAttachments($id, $request);
|
||||||
$returnLogisticsNodes = [];
|
$returnLogisticsNodes = [];
|
||||||
if ($returnLogistics) {
|
if ($returnLogistics) {
|
||||||
$returnLogisticsNodes = Db::name('order_logistics_nodes')
|
$returnLogisticsNodes = Db::name('order_logistics_nodes')
|
||||||
@@ -352,6 +368,9 @@ class OrdersController
|
|||||||
)),
|
)),
|
||||||
] : null,
|
] : null,
|
||||||
'timeline' => $timeline,
|
'timeline' => $timeline,
|
||||||
|
'transfer_flow' => $transferFlow ? [
|
||||||
|
'internal_tag_no' => (string)($transferFlow['internal_tag_no'] ?? ''),
|
||||||
|
] : null,
|
||||||
'logistics_info' => $sendLogistics ? [
|
'logistics_info' => $sendLogistics ? [
|
||||||
'express_company' => $sendLogistics['express_company'],
|
'express_company' => $sendLogistics['express_company'],
|
||||||
'tracking_no' => $sendLogistics['tracking_no'],
|
'tracking_no' => $sendLogistics['tracking_no'],
|
||||||
@@ -377,6 +396,7 @@ class OrdersController
|
|||||||
'node_location' => $item['node_location'],
|
'node_location' => $item['node_location'],
|
||||||
], $logisticsNodes),
|
], $logisticsNodes),
|
||||||
] : null,
|
] : null,
|
||||||
|
'inbound_attachments' => $inboundAttachments,
|
||||||
'return_logistics' => $returnLogistics ? [
|
'return_logistics' => $returnLogistics ? [
|
||||||
'express_company' => $returnLogistics['express_company'],
|
'express_company' => $returnLogistics['express_company'],
|
||||||
'tracking_no' => $returnLogistics['tracking_no'],
|
'tracking_no' => $returnLogistics['tracking_no'],
|
||||||
@@ -907,6 +927,440 @@ class OrdersController
|
|||||||
return api_success(['id' => $id], '已标记用户签收');
|
return api_success(['id' => $id], '已标记用户签收');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createManualOrder(Request $request)
|
||||||
|
{
|
||||||
|
$serviceProvider = $this->normalizeServiceProvider((string)$request->input('service_provider', 'anxinyan'));
|
||||||
|
$productInput = $this->requestArray($request, 'product_info');
|
||||||
|
$extraInput = $this->requestArray($request, 'extra_info');
|
||||||
|
$returnAddressInput = $this->requestArray($request, 'return_address');
|
||||||
|
$materialsInput = $request->input('materials', []);
|
||||||
|
$materials = is_array($materialsInput) ? $materialsInput : [];
|
||||||
|
|
||||||
|
$categoryId = (int)($productInput['category_id'] ?? 0);
|
||||||
|
$brandId = (int)($productInput['brand_id'] ?? 0);
|
||||||
|
$productName = trim((string)($productInput['product_name'] ?? ''));
|
||||||
|
$consignee = trim((string)($returnAddressInput['consignee'] ?? ''));
|
||||||
|
$mobile = trim((string)($returnAddressInput['mobile'] ?? ''));
|
||||||
|
$province = trim((string)($returnAddressInput['province'] ?? ''));
|
||||||
|
$city = trim((string)($returnAddressInput['city'] ?? ''));
|
||||||
|
$district = trim((string)($returnAddressInput['district'] ?? ''));
|
||||||
|
$detailAddress = trim((string)($returnAddressInput['detail_address'] ?? ''));
|
||||||
|
|
||||||
|
if ($serviceProvider === '') {
|
||||||
|
return api_error('服务类型不正确', 422);
|
||||||
|
}
|
||||||
|
if ($categoryId <= 0 || $brandId <= 0 || $productName === '') {
|
||||||
|
return api_error('请完整填写品类、品牌和商品名称', 422);
|
||||||
|
}
|
||||||
|
if ($consignee === '' || $mobile === '' || $province === '' || $city === '' || $district === '' || $detailAddress === '') {
|
||||||
|
return api_error('请完整填写寄回收件信息', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = Db::name('catalog_categories')->where('id', $categoryId)->find();
|
||||||
|
if (!$category) {
|
||||||
|
return api_error('品类不存在', 422);
|
||||||
|
}
|
||||||
|
$brand = Db::name('catalog_brands')->where('id', $brandId)->find();
|
||||||
|
if (!$brand) {
|
||||||
|
return api_error('品牌不存在', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$serviceConfig = $this->serviceConfig($serviceProvider);
|
||||||
|
$orderNo = $this->generateOrderNo();
|
||||||
|
$appraisalNo = $this->generateAppraisalNo();
|
||||||
|
$estimated = date('Y-m-d H:i:s', strtotime(sprintf('+%d hours', (int)$serviceConfig['sla_hours'])));
|
||||||
|
$operatorId = (int)$request->header('x-admin-id', 0) ?: null;
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
$user = $this->resolveManualOrderUser($consignee, $mobile, $now);
|
||||||
|
$addressId = $this->ensureUserAddress((int)$user['id'], [
|
||||||
|
'consignee' => $consignee,
|
||||||
|
'mobile' => $mobile,
|
||||||
|
'province' => $province,
|
||||||
|
'city' => $city,
|
||||||
|
'district' => $district,
|
||||||
|
'detail_address' => $detailAddress,
|
||||||
|
], $now);
|
||||||
|
|
||||||
|
$orderId = (int)Db::name('orders')->insertGetId([
|
||||||
|
'order_no' => $orderNo,
|
||||||
|
'appraisal_no' => $appraisalNo,
|
||||||
|
'user_id' => (int)$user['id'],
|
||||||
|
'service_mode' => 'physical',
|
||||||
|
'service_provider' => $serviceProvider,
|
||||||
|
'payment_status' => 'paid',
|
||||||
|
'order_status' => 'pending_shipping',
|
||||||
|
'display_status' => '待入库',
|
||||||
|
'estimated_finish_time' => $estimated,
|
||||||
|
'source_channel' => self::MANUAL_ENTRY_SOURCE,
|
||||||
|
'source_customer_id' => '',
|
||||||
|
'pay_amount' => (float)$serviceConfig['price'],
|
||||||
|
'paid_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::name('order_products')->insert([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
'category_name' => (string)$category['name'],
|
||||||
|
'brand_id' => $brandId,
|
||||||
|
'brand_name' => (string)$brand['name'],
|
||||||
|
'color' => trim((string)($productInput['color'] ?? '')),
|
||||||
|
'size_spec' => trim((string)($productInput['size_spec'] ?? '')),
|
||||||
|
'serial_no' => trim((string)($productInput['serial_no'] ?? '')),
|
||||||
|
'product_name' => $productName,
|
||||||
|
'product_cover' => '',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::name('order_extras')->insert([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'purchase_channel' => trim((string)($extraInput['purchase_channel'] ?? '')),
|
||||||
|
'purchase_price' => (float)($extraInput['purchase_price'] ?? 0),
|
||||||
|
'purchase_date' => null,
|
||||||
|
'usage_status' => trim((string)($extraInput['usage_status'] ?? '')),
|
||||||
|
'condition_desc' => trim((string)($extraInput['condition_desc'] ?? '')),
|
||||||
|
'has_accessories' => 0,
|
||||||
|
'accessories_json' => json_encode([], JSON_UNESCAPED_UNICODE),
|
||||||
|
'remark' => trim((string)($extraInput['remark'] ?? '')),
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::name('order_return_addresses')->insert([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'user_address_id' => $addressId,
|
||||||
|
'consignee' => $consignee,
|
||||||
|
'mobile' => $mobile,
|
||||||
|
'province' => $province,
|
||||||
|
'city' => $city,
|
||||||
|
'district' => $district,
|
||||||
|
'detail_address' => $detailAddress,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$shippingTarget = (new WarehouseService())->bindOrderTarget($orderId, $serviceProvider, $categoryId, [
|
||||||
|
'province' => $province,
|
||||||
|
'city' => $city,
|
||||||
|
'district' => $district,
|
||||||
|
'detail_address' => $detailAddress,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->insertManualOrderMaterials($orderId, $materials, $now);
|
||||||
|
|
||||||
|
Db::name('appraisal_tasks')->insert([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'task_stage' => 'first_review',
|
||||||
|
'service_provider' => $serviceProvider,
|
||||||
|
'status' => 'pending',
|
||||||
|
'assignee_id' => null,
|
||||||
|
'assignee_name' => '未分配',
|
||||||
|
'started_at' => null,
|
||||||
|
'submitted_at' => null,
|
||||||
|
'sla_deadline' => $estimated,
|
||||||
|
'is_overtime' => 0,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::name('order_timelines')->insertAll([
|
||||||
|
[
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'node_code' => 'manual_created',
|
||||||
|
'node_text' => '补录订单已创建',
|
||||||
|
'node_desc' => '后台已补录订单资料,等待仓管入库。',
|
||||||
|
'operator_type' => 'admin',
|
||||||
|
'operator_id' => $operatorId,
|
||||||
|
'occurred_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'node_code' => 'pending_inbound',
|
||||||
|
'node_text' => '待入库',
|
||||||
|
'node_desc' => sprintf('可使用订单号或鉴定单号匹配入库,目标仓库:%s。', $shippingTarget['warehouse_name'] ?: '鉴定中心'),
|
||||||
|
'operator_type' => 'system',
|
||||||
|
'operator_id' => null,
|
||||||
|
'occurred_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
return api_error('补录订单创建失败', 500, ['detail' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return api_success([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'order_no' => $orderNo,
|
||||||
|
'appraisal_no' => $appraisalNo,
|
||||||
|
'user_id' => (int)$user['id'],
|
||||||
|
'next_status' => 'pending_shipping',
|
||||||
|
], '补录订单已创建');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function manualOrderMeta(Request $request)
|
||||||
|
{
|
||||||
|
$categories = Db::name('catalog_categories')
|
||||||
|
->field(['id', 'name', 'code', 'is_enabled', 'supported_service_types'])
|
||||||
|
->where('is_enabled', 1)
|
||||||
|
->order('sort_order', 'asc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
$brands = Db::name('catalog_brands')
|
||||||
|
->alias('b')
|
||||||
|
->leftJoin('catalog_brand_categories cbc', 'cbc.brand_id = b.id')
|
||||||
|
->field([
|
||||||
|
'b.id',
|
||||||
|
'b.name',
|
||||||
|
'b.en_name',
|
||||||
|
'b.code',
|
||||||
|
'b.is_enabled',
|
||||||
|
'b.supported_service_types',
|
||||||
|
'GROUP_CONCAT(DISTINCT cbc.category_id) AS category_ids',
|
||||||
|
])
|
||||||
|
->where('b.is_enabled', 1)
|
||||||
|
->group('b.id')
|
||||||
|
->order('b.sort_order', 'asc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return api_success([
|
||||||
|
'categories' => array_map(fn (array $item) => [
|
||||||
|
'id' => (int)$item['id'],
|
||||||
|
'name' => (string)$item['name'],
|
||||||
|
'code' => (string)$item['code'],
|
||||||
|
'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null),
|
||||||
|
], $categories),
|
||||||
|
'brands' => array_map(fn (array $item) => [
|
||||||
|
'id' => (int)$item['id'],
|
||||||
|
'name' => (string)$item['name'],
|
||||||
|
'en_name' => (string)($item['en_name'] ?? ''),
|
||||||
|
'code' => (string)($item['code'] ?? ''),
|
||||||
|
'category_ids' => $this->decodeIntList($item['category_ids'] ?? ''),
|
||||||
|
'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null),
|
||||||
|
], $brands),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadManualOrderFile(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success((new AppraisalEvidenceService())->upload($request));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return api_error($e->getMessage(), 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inboundAttachments(int $orderId, Request $request): array
|
||||||
|
{
|
||||||
|
$logs = Db::name('order_transfer_flow_logs')
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->where('action_code', 'inbound_received')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$attachments = [];
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
$payload = $this->decodeJsonObject($log['payload_json'] ?? null);
|
||||||
|
foreach ($this->decodeJsonArray($payload['inbound_attachments'] ?? []) as $item) {
|
||||||
|
if (is_array($item)) {
|
||||||
|
$attachments[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = (new AppraisalEvidenceService())->normalize($attachments, $request);
|
||||||
|
|
||||||
|
return array_values(array_filter($normalized, function (array $item) {
|
||||||
|
return in_array((string)($item['file_type'] ?? ''), ['image', 'video'], true);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeJsonArray(mixed $value): array
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
return array_values($value);
|
||||||
|
}
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
return is_array($decoded) ? array_values($decoded) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeJsonObject(mixed $value): array
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeIntList(mixed $value): array
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
return array_values(array_filter(array_map('intval', $value), fn (int $item) => $item > 0));
|
||||||
|
}
|
||||||
|
if (!is_string($value) || trim($value) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map('intval', explode(',', $value)), fn (int $item) => $item > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requestArray(Request $request, string $key): array
|
||||||
|
{
|
||||||
|
$value = $request->input($key, []);
|
||||||
|
return is_array($value) ? $value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeServiceProvider(string $serviceProvider): string
|
||||||
|
{
|
||||||
|
$serviceProvider = trim($serviceProvider);
|
||||||
|
return in_array($serviceProvider, ['anxinyan', 'zhongjian'], true) ? $serviceProvider : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceConfig(string $serviceProvider): array
|
||||||
|
{
|
||||||
|
$configs = [
|
||||||
|
'anxinyan' => ['price' => 99.00, 'sla_hours' => 48],
|
||||||
|
'zhongjian' => ['price' => 199.00, 'sla_hours' => 72],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $configs[$serviceProvider] ?? $configs['anxinyan'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateOrderNo(): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$orderNo = 'AXY' . date('YmdHis') . mt_rand(100, 999);
|
||||||
|
} while (Db::name('orders')->where('order_no', $orderNo)->find());
|
||||||
|
|
||||||
|
return $orderNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateAppraisalNo(): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$appraisalNo = 'AXY-APP-' . date('Ymd') . '-' . mt_rand(1000, 9999);
|
||||||
|
} while (Db::name('orders')->where('appraisal_no', $appraisalNo)->find());
|
||||||
|
|
||||||
|
return $appraisalNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveManualOrderUser(string $consignee, string $mobile, string $now): array
|
||||||
|
{
|
||||||
|
$user = Db::name('users')
|
||||||
|
->where('mobile', $mobile)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->find();
|
||||||
|
if ($user) {
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int)Db::name('users')->insertGetId([
|
||||||
|
'nickname' => $consignee,
|
||||||
|
'avatar' => '',
|
||||||
|
'mobile' => $mobile,
|
||||||
|
'password' => '',
|
||||||
|
'status' => 'enabled',
|
||||||
|
'last_login_at' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Db::name('users')->where('id', $userId)->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureUserAddress(int $userId, array $address, string $now): int
|
||||||
|
{
|
||||||
|
$existing = Db::name('user_addresses')
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('consignee', (string)$address['consignee'])
|
||||||
|
->where('mobile', (string)$address['mobile'])
|
||||||
|
->where('province', (string)$address['province'])
|
||||||
|
->where('city', (string)$address['city'])
|
||||||
|
->where('district', (string)$address['district'])
|
||||||
|
->where('detail_address', (string)$address['detail_address'])
|
||||||
|
->find();
|
||||||
|
if ($existing) {
|
||||||
|
return (int)$existing['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasDefault = Db::name('user_addresses')
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('is_default', 1)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
return (int)Db::name('user_addresses')->insertGetId([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'consignee' => (string)$address['consignee'],
|
||||||
|
'mobile' => (string)$address['mobile'],
|
||||||
|
'province' => (string)$address['province'],
|
||||||
|
'city' => (string)$address['city'],
|
||||||
|
'district' => (string)$address['district'],
|
||||||
|
'detail_address' => (string)$address['detail_address'],
|
||||||
|
'is_default' => $hasDefault ? 0 : 1,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function insertManualOrderMaterials(int $orderId, array $materials, string $now): void
|
||||||
|
{
|
||||||
|
$evidenceService = new AppraisalEvidenceService();
|
||||||
|
foreach ($materials as $index => $item) {
|
||||||
|
if (!is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$files = $evidenceService->normalize($item['files'] ?? [], null, true);
|
||||||
|
if (!$files) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderUploadId = (int)Db::name('order_upload_items')->insertGetId([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'template_id' => null,
|
||||||
|
'item_code' => trim((string)($item['item_code'] ?? 'manual_material_' . ($index + 1))),
|
||||||
|
'item_name' => trim((string)($item['item_name'] ?? '补录资料')),
|
||||||
|
'is_required' => !empty($item['is_required']) ? 1 : 0,
|
||||||
|
'source_type' => 'initial',
|
||||||
|
'status' => 'uploaded',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
Db::name('order_upload_files')->insert([
|
||||||
|
'order_upload_item_id' => $orderUploadId,
|
||||||
|
'file_id' => (string)($file['file_id'] ?? ''),
|
||||||
|
'file_url' => ltrim((string)($file['file_url'] ?? ''), '/'),
|
||||||
|
'thumbnail_url' => ltrim((string)($file['thumbnail_url'] ?? ''), '/'),
|
||||||
|
'quality_status' => 'uploaded',
|
||||||
|
'quality_message' => '',
|
||||||
|
'uploaded_by_user_id' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function trackingStatusText(string $status, string $logisticsType = 'send_to_center'): string
|
private function trackingStatusText(string $status, string $logisticsType = 'send_to_center'): string
|
||||||
{
|
{
|
||||||
if ($logisticsType === 'return_to_user') {
|
if ($logisticsType === 'return_to_user') {
|
||||||
@@ -984,14 +1438,45 @@ class OrdersController
|
|||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function latestTransferFlowMap(array $orderIds): array
|
||||||
|
{
|
||||||
|
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
|
||||||
|
if (!$orderIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = Db::name('order_transfer_flows')
|
||||||
|
->whereIn('order_id', $orderIds)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$orderId = (int)($row['order_id'] ?? 0);
|
||||||
|
if ($orderId > 0 && !isset($map[$orderId])) {
|
||||||
|
$map[$orderId] = [
|
||||||
|
'internal_tag_no' => (string)($row['internal_tag_no'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
private function warehouseOrderBucket(
|
private function warehouseOrderBucket(
|
||||||
string $orderStatus,
|
string $orderStatus,
|
||||||
string $sendTrackingNo = '',
|
string $sendTrackingNo = '',
|
||||||
string $sendTrackingStatus = '',
|
string $sendTrackingStatus = '',
|
||||||
string $displayStatus = ''
|
string $displayStatus = '',
|
||||||
|
string $sourceChannel = ''
|
||||||
): string
|
): string
|
||||||
{
|
{
|
||||||
if ($orderStatus === 'pending_shipping') {
|
if ($orderStatus === 'pending_shipping') {
|
||||||
|
if ($sourceChannel === self::MANUAL_ENTRY_SOURCE && $sendTrackingNo === '') {
|
||||||
|
return 'warehouse_pending_inbound';
|
||||||
|
}
|
||||||
|
|
||||||
$hasSubmittedTracking = $sendTrackingNo !== '' && $sendTrackingStatus !== 'received';
|
$hasSubmittedTracking = $sendTrackingNo !== '' && $sendTrackingStatus !== 'received';
|
||||||
$hasSubmittedDisplayStatus = in_array($displayStatus, ['已提交运单', '用户已提交运单'], true)
|
$hasSubmittedDisplayStatus = in_array($displayStatus, ['已提交运单', '用户已提交运单'], true)
|
||||||
&& $sendTrackingStatus !== 'received';
|
&& $sendTrackingStatus !== 'received';
|
||||||
@@ -1020,6 +1505,7 @@ class OrdersController
|
|||||||
private function warehouseOrderBucketText(string $bucket): string
|
private function warehouseOrderBucketText(string $bucket): string
|
||||||
{
|
{
|
||||||
return match ($bucket) {
|
return match ($bucket) {
|
||||||
|
'warehouse_pending_inbound' => '待入库',
|
||||||
'warehouse_in_transit' => '在途',
|
'warehouse_in_transit' => '在途',
|
||||||
'warehouse_received' => '已入仓',
|
'warehouse_received' => '已入仓',
|
||||||
'warehouse_pending_return' => '待寄回',
|
'warehouse_pending_return' => '待寄回',
|
||||||
@@ -1041,10 +1527,13 @@ class OrdersController
|
|||||||
'enterprise_order' => 'enterprise_push',
|
'enterprise_order' => 'enterprise_push',
|
||||||
'customer_push' => 'enterprise_push',
|
'customer_push' => 'enterprise_push',
|
||||||
'large_customer_push' => 'enterprise_push',
|
'large_customer_push' => 'enterprise_push',
|
||||||
|
'manual' => self::MANUAL_ENTRY_SOURCE,
|
||||||
|
'manual_order' => self::MANUAL_ENTRY_SOURCE,
|
||||||
|
'manual_entry' => self::MANUAL_ENTRY_SOURCE,
|
||||||
];
|
];
|
||||||
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
|
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
|
||||||
|
|
||||||
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) ? $sourceChannel : '';
|
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push', self::MANUAL_ENTRY_SOURCE], true) ? $sourceChannel : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sourceChannelText(string $sourceChannel): string
|
private function sourceChannelText(string $sourceChannel): string
|
||||||
@@ -1053,6 +1542,7 @@ class OrdersController
|
|||||||
'mini_program' => '小程序',
|
'mini_program' => '小程序',
|
||||||
'h5' => 'H5',
|
'h5' => 'H5',
|
||||||
'enterprise_push' => '大客户推送订单',
|
'enterprise_push' => '大客户推送订单',
|
||||||
|
self::MANUAL_ENTRY_SOURCE => '后台补录订单',
|
||||||
default => '未知渠道',
|
default => '未知渠道',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace app\controller\admin;
|
|||||||
use app\support\AppraisalEvidenceService;
|
use app\support\AppraisalEvidenceService;
|
||||||
use app\support\ContentService;
|
use app\support\ContentService;
|
||||||
use app\support\EnterpriseWebhookService;
|
use app\support\EnterpriseWebhookService;
|
||||||
|
use app\support\FulfillmentFlowService;
|
||||||
|
use app\support\MaterialTagService;
|
||||||
use app\support\MessageDispatcher;
|
use app\support\MessageDispatcher;
|
||||||
use support\Request;
|
use support\Request;
|
||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
@@ -24,6 +26,7 @@ class ReportsController
|
|||||||
->alias('r')
|
->alias('r')
|
||||||
->leftJoin('orders o', 'o.id = r.order_id')
|
->leftJoin('orders o', 'o.id = r.order_id')
|
||||||
->leftJoin('order_products p', 'p.order_id = r.order_id')
|
->leftJoin('order_products p', 'p.order_id = r.order_id')
|
||||||
|
->leftJoin('material_tag_codes mt', 'mt.report_id = r.id')
|
||||||
->field([
|
->field([
|
||||||
'r.id',
|
'r.id',
|
||||||
'r.report_no',
|
'r.report_no',
|
||||||
@@ -42,6 +45,9 @@ class ReportsController
|
|||||||
'p.product_name',
|
'p.product_name',
|
||||||
'p.category_name',
|
'p.category_name',
|
||||||
'p.brand_name',
|
'p.brand_name',
|
||||||
|
'mt.id as material_tag_id',
|
||||||
|
'mt.verify_code as material_tag_verify_code',
|
||||||
|
'mt.bind_status as material_tag_bind_status',
|
||||||
])
|
])
|
||||||
->order('r.id', 'desc');
|
->order('r.id', 'desc');
|
||||||
|
|
||||||
@@ -80,6 +86,9 @@ class ReportsController
|
|||||||
'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''),
|
'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''),
|
||||||
'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''),
|
'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''),
|
||||||
'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''),
|
'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''),
|
||||||
|
'material_tag_bound' => (int)($item['material_tag_id'] ?? 0) > 0,
|
||||||
|
'material_tag_verify_code' => (string)($item['material_tag_verify_code'] ?? ''),
|
||||||
|
'material_tag_bind_status' => (string)($item['material_tag_bind_status'] ?? ''),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($keyword !== '' && !$this->matchKeyword($mapped, $keyword)) {
|
if ($keyword !== '' && !$this->matchKeyword($mapped, $keyword)) {
|
||||||
@@ -125,21 +134,21 @@ class ReportsController
|
|||||||
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
|
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
|
||||||
$appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot);
|
$appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot);
|
||||||
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
||||||
|
$materialTag = (new MaterialTagService())->findBoundTagForReport($id);
|
||||||
|
|
||||||
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
|
$verify = Db::name('report_verifies')->where('report_id', $id)->find() ?: [];
|
||||||
$verify = $usesPlatformVerify ? (Db::name('report_verifies')->where('report_id', $id)->find() ?: []) : [];
|
if (($report['report_status'] ?? '') === 'published') {
|
||||||
if ($usesPlatformVerify && ($report['report_status'] ?? '') === 'published') {
|
|
||||||
$verify = $this->createOrUpdateVerifyRecord($report, date('Y-m-d H:i:s'));
|
$verify = $this->createOrUpdateVerifyRecord($report, date('Y-m-d H:i:s'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$reportPageUrl = $usesPlatformVerify ? $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]) : '';
|
$reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]);
|
||||||
$verifyUrl = $usesPlatformVerify ? $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]) : '';
|
$verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]);
|
||||||
if (!$verify) {
|
if (!$verify) {
|
||||||
$verify = [];
|
$verify = [];
|
||||||
}
|
}
|
||||||
$verify['report_page_url'] = $usesPlatformVerify ? ($verify['report_page_url'] ?? $reportPageUrl) : '';
|
$verify['report_page_url'] = $verify['report_page_url'] ?? $reportPageUrl;
|
||||||
$verify['verify_qrcode_url'] = $usesPlatformVerify ? ($verify['verify_qrcode_url'] ?? $reportPageUrl) : '';
|
$verify['verify_qrcode_url'] = $verify['verify_qrcode_url'] ?? $reportPageUrl;
|
||||||
$verify['verify_url'] = $usesPlatformVerify ? ($verify['verify_url'] ?? $verifyUrl) : '';
|
$verify['verify_url'] = $verify['verify_url'] ?? $verifyUrl;
|
||||||
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal'));
|
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal'));
|
||||||
|
|
||||||
return api_success([
|
return api_success([
|
||||||
@@ -167,6 +176,7 @@ class ReportsController
|
|||||||
'valuation_info' => $valuationSnapshot,
|
'valuation_info' => $valuationSnapshot,
|
||||||
'evidence_attachments' => $evidenceAttachments,
|
'evidence_attachments' => $evidenceAttachments,
|
||||||
'zhongjian_report_files' => $zhongjianReportFiles,
|
'zhongjian_report_files' => $zhongjianReportFiles,
|
||||||
|
'material_tag' => $materialTag,
|
||||||
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
|
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
|
||||||
'verify_info' => [
|
'verify_info' => [
|
||||||
'verify_status' => $verify['verify_status'] ?? (($report['report_status'] ?? '') === 'published' ? 'valid' : 'pending'),
|
'verify_status' => $verify['verify_status'] ?? (($report['report_status'] ?? '') === 'published' ? 'valid' : 'pending'),
|
||||||
@@ -333,11 +343,9 @@ class ReportsController
|
|||||||
'verify_url' => '',
|
'verify_url' => '',
|
||||||
'report_page_url' => '',
|
'report_page_url' => '',
|
||||||
];
|
];
|
||||||
$usesPlatformVerify = $serviceProvider !== 'zhongjian';
|
if ($reportStatus === 'published' && $reportRecord) {
|
||||||
|
|
||||||
if ($reportStatus === 'published' && $reportRecord && $usesPlatformVerify) {
|
|
||||||
$verifyInfo = $this->createOrUpdateVerifyRecord($reportRecord, $now);
|
$verifyInfo = $this->createOrUpdateVerifyRecord($reportRecord, $now);
|
||||||
} else {
|
} elseif ($reportStatus !== 'published') {
|
||||||
Db::name('report_verifies')->where('report_id', $reportId)->delete();
|
Db::name('report_verifies')->where('report_id', $reportId)->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +369,7 @@ class ReportsController
|
|||||||
public function publish(Request $request)
|
public function publish(Request $request)
|
||||||
{
|
{
|
||||||
$id = (int)$request->input('id', 0);
|
$id = (int)$request->input('id', 0);
|
||||||
|
$qrInput = trim((string)$request->input('qr_input', ''));
|
||||||
if (!$id) {
|
if (!$id) {
|
||||||
return api_error('报告 ID 不能为空', 422);
|
return api_error('报告 ID 不能为空', 422);
|
||||||
}
|
}
|
||||||
@@ -381,7 +390,29 @@ class ReportsController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effectivePublishTime = $report['publish_time'] ?: $now;
|
$effectivePublishTime = $report['publish_time'] ?: $now;
|
||||||
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
|
$isOrderAppraisalReport = ($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0;
|
||||||
|
$materialTag = null;
|
||||||
|
if ($isOrderAppraisalReport) {
|
||||||
|
$materialTag = (new MaterialTagService())->findBoundTagForReport($id);
|
||||||
|
if (!$materialTag) {
|
||||||
|
if ($qrInput === '') {
|
||||||
|
Db::rollback();
|
||||||
|
return api_error('请扫描验真吊牌二维码后再发布报告', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$task = Db::name('appraisal_tasks')
|
||||||
|
->where('order_id', (int)$report['order_id'])
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
if (!$task) {
|
||||||
|
Db::rollback();
|
||||||
|
return api_error('报告未关联鉴定任务,不能绑定吊牌发布', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$materialTag = (new MaterialTagService())->bindTagToReportByTask((int)$task['id'], $qrInput, $request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($report['report_status'] !== 'published') {
|
if ($report['report_status'] !== 'published') {
|
||||||
Db::name('reports')->where('id', $id)->update([
|
Db::name('reports')->where('id', $id)->update([
|
||||||
'report_status' => 'published',
|
'report_status' => 'published',
|
||||||
@@ -392,18 +423,13 @@ class ReportsController
|
|||||||
$report['publish_time'] = $effectivePublishTime;
|
$report['publish_time'] = $effectivePublishTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
if ($isOrderAppraisalReport) {
|
||||||
$this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now);
|
$this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now);
|
||||||
}
|
}
|
||||||
|
|
||||||
$verify = [];
|
|
||||||
if ($usesPlatformVerify) {
|
|
||||||
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
$verify = $this->createOrUpdateVerifyRecord($report, $now);
|
||||||
} else {
|
|
||||||
Db::name('report_verifies')->where('report_id', $id)->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
if ($isOrderAppraisalReport) {
|
||||||
Db::name('orders')->where('id', $report['order_id'])->update([
|
Db::name('orders')->where('id', $report['order_id'])->update([
|
||||||
'order_status' => 'report_published',
|
'order_status' => 'report_published',
|
||||||
'display_status' => '报告已出具',
|
'display_status' => '报告已出具',
|
||||||
@@ -424,7 +450,7 @@ class ReportsController
|
|||||||
'order_id' => $report['order_id'],
|
'order_id' => $report['order_id'],
|
||||||
'node_code' => 'report_published',
|
'node_code' => 'report_published',
|
||||||
'node_text' => '报告已出具',
|
'node_text' => '报告已出具',
|
||||||
'node_desc' => $usesPlatformVerify ? '正式报告已发布,用户可查看报告并进行验真。' : '中检报告已发布,用户可查看报告。',
|
'node_desc' => '正式报告已发布,用户可查看报告并进行验真。',
|
||||||
'operator_type' => 'admin',
|
'operator_type' => 'admin',
|
||||||
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
|
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
|
||||||
'occurred_at' => $now,
|
'occurred_at' => $now,
|
||||||
@@ -440,22 +466,24 @@ class ReportsController
|
|||||||
'report_title' => $report['report_title'],
|
'report_title' => $report['report_title'],
|
||||||
'product_name' => $product['product_name'] ?? '',
|
'product_name' => $product['product_name'] ?? '',
|
||||||
'publish_time' => $report['publish_time'] ?: $now,
|
'publish_time' => $report['publish_time'] ?: $now,
|
||||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||||
'fallback_title' => '报告已出具',
|
'fallback_title' => '报告已出具',
|
||||||
'fallback_content' => $usesPlatformVerify ? '您的正式报告已生成,可前往报告中心查看并完成验真。' : '您的中检报告已生成,可前往报告中心查看。',
|
'fallback_content' => '您的正式报告已生成,可前往报告中心查看并完成验真。',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
(new FulfillmentFlowService())->markReportPublished((int)$report['order_id'], $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
|
||||||
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
|
if ($isOrderAppraisalReport) {
|
||||||
(new EnterpriseWebhookService())->recordOrderEvent((int)$report['order_id'], 'report_published', [
|
(new EnterpriseWebhookService())->recordOrderEvent((int)$report['order_id'], 'report_published', [
|
||||||
'report_id' => $id,
|
'report_id' => $id,
|
||||||
'report_no' => (string)$report['report_no'],
|
'report_no' => (string)$report['report_no'],
|
||||||
'report_title' => (string)$report['report_title'],
|
'report_title' => (string)$report['report_title'],
|
||||||
'publish_time' => $effectivePublishTime,
|
'publish_time' => $effectivePublishTime,
|
||||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||||
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
|
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,8 +491,9 @@ class ReportsController
|
|||||||
'id' => $id,
|
'id' => $id,
|
||||||
'report_status' => 'published',
|
'report_status' => 'published',
|
||||||
'publish_time' => $effectivePublishTime,
|
'publish_time' => $effectivePublishTime,
|
||||||
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
|
'verify_url' => (string)($verify['verify_url'] ?? ''),
|
||||||
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
|
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
|
||||||
|
'material_tag' => $materialTag,
|
||||||
], '报告已发布');
|
], '报告已发布');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace app\controller\admin;
|
namespace app\controller\admin;
|
||||||
|
|
||||||
|
use app\support\AppraisalEvidenceService;
|
||||||
use app\support\FulfillmentFlowService;
|
use app\support\FulfillmentFlowService;
|
||||||
use support\Request;
|
use support\Request;
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ class WarehouseWorkbenchController
|
|||||||
public function inboundLookup(Request $request)
|
public function inboundLookup(Request $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
return api_success($this->service()->lookupInboundByTrackingNo((string)$request->input('tracking_no', '')));
|
return api_success($this->service()->lookupInboundByInboundNo($this->inboundNo($request)));
|
||||||
} catch (\InvalidArgumentException $e) {
|
} catch (\InvalidArgumentException $e) {
|
||||||
return api_error($e->getMessage(), 422);
|
return api_error($e->getMessage(), 422);
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
@@ -24,9 +25,10 @@ class WarehouseWorkbenchController
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
return api_success($this->service()->receiveInbound(
|
return api_success($this->service()->receiveInbound(
|
||||||
(string)$request->input('tracking_no', ''),
|
$this->inboundNo($request),
|
||||||
(string)$request->input('internal_tag_no', ''),
|
(string)$request->input('internal_tag_no', ''),
|
||||||
$request
|
$request,
|
||||||
|
$request->input('inbound_attachments', [])
|
||||||
), '入库完成');
|
), '入库完成');
|
||||||
} catch (\InvalidArgumentException $e) {
|
} catch (\InvalidArgumentException $e) {
|
||||||
return api_error($e->getMessage(), 422);
|
return api_error($e->getMessage(), 422);
|
||||||
@@ -37,6 +39,38 @@ class WarehouseWorkbenchController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function uploadInboundEvidenceFile(Request $request)
|
||||||
|
{
|
||||||
|
$evidenceService = new AppraisalEvidenceService();
|
||||||
|
try {
|
||||||
|
$asset = $evidenceService->upload($request);
|
||||||
|
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
|
||||||
|
$evidenceService->delete((string)($asset['file_url'] ?? ''));
|
||||||
|
return api_error('拆包附件仅支持上传图片或视频', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return api_success($asset);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return api_error($e->getMessage(), 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadReturnPackingFile(Request $request)
|
||||||
|
{
|
||||||
|
$evidenceService = new AppraisalEvidenceService();
|
||||||
|
try {
|
||||||
|
$asset = $evidenceService->upload($request);
|
||||||
|
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
|
||||||
|
$evidenceService->delete((string)($asset['file_url'] ?? ''));
|
||||||
|
return api_error('打包装箱附件仅支持上传图片或视频', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return api_success($asset);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return api_error($e->getMessage(), 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function zhongjianLookup(Request $request)
|
public function zhongjianLookup(Request $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -96,7 +130,7 @@ class WarehouseWorkbenchController
|
|||||||
(string)$request->input('internal_tag_no', ''),
|
(string)$request->input('internal_tag_no', ''),
|
||||||
(string)$request->input('qr_input', ''),
|
(string)$request->input('qr_input', ''),
|
||||||
$request
|
$request
|
||||||
), '验真吊牌已确认');
|
), '验真吊牌匹配通过,请核对报告');
|
||||||
} catch (\InvalidArgumentException $e) {
|
} catch (\InvalidArgumentException $e) {
|
||||||
return api_error($e->getMessage(), 422);
|
return api_error($e->getMessage(), 422);
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
@@ -119,6 +153,23 @@ class WarehouseWorkbenchController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function confirmReturnReport(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return api_success($this->service()->confirmReturnReport(
|
||||||
|
(string)$request->input('internal_tag_no', ''),
|
||||||
|
(int)$request->input('report_id', 0),
|
||||||
|
$request
|
||||||
|
), '报告已确认,可填写回寄运单');
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return api_error($e->getMessage(), 422);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
return api_error($e->getMessage(), $e->getCode() ?: 404);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return api_error('报告确认失败', 500, ['detail' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function shipReturn(Request $request)
|
public function shipReturn(Request $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -126,7 +177,8 @@ class WarehouseWorkbenchController
|
|||||||
(string)$request->input('internal_tag_no', ''),
|
(string)$request->input('internal_tag_no', ''),
|
||||||
(string)$request->input('express_company', ''),
|
(string)$request->input('express_company', ''),
|
||||||
(string)$request->input('tracking_no', ''),
|
(string)$request->input('tracking_no', ''),
|
||||||
$request
|
$request,
|
||||||
|
$request->input('packing_attachments', [])
|
||||||
), '回寄运单已登记');
|
), '回寄运单已登记');
|
||||||
} catch (\InvalidArgumentException $e) {
|
} catch (\InvalidArgumentException $e) {
|
||||||
return api_error($e->getMessage(), 422);
|
return api_error($e->getMessage(), 422);
|
||||||
@@ -141,4 +193,26 @@ class WarehouseWorkbenchController
|
|||||||
{
|
{
|
||||||
return new FulfillmentFlowService();
|
return new FulfillmentFlowService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function inboundNo(Request $request): string
|
||||||
|
{
|
||||||
|
$inboundNo = $this->requestString($request, 'inbound_no');
|
||||||
|
if ($inboundNo !== '') {
|
||||||
|
return $inboundNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->requestString($request, 'tracking_no');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requestString(Request $request, string $key): string
|
||||||
|
{
|
||||||
|
foreach ([$request->get($key, null), $request->post($key, null), $request->input($key, null)] as $value) {
|
||||||
|
$text = trim((string)($value ?? ''));
|
||||||
|
if ($text !== '') {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,10 +466,12 @@ class OrdersController
|
|||||||
'enterprise_order' => 'enterprise_push',
|
'enterprise_order' => 'enterprise_push',
|
||||||
'customer_push' => 'enterprise_push',
|
'customer_push' => 'enterprise_push',
|
||||||
'large_customer_push' => 'enterprise_push',
|
'large_customer_push' => 'enterprise_push',
|
||||||
|
'manual' => 'manual_entry',
|
||||||
|
'manual_order' => 'manual_entry',
|
||||||
];
|
];
|
||||||
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
|
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
|
||||||
|
|
||||||
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) ? $sourceChannel : '';
|
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push', 'manual_entry'], true) ? $sourceChannel : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sourceChannelText(string $sourceChannel): string
|
private function sourceChannelText(string $sourceChannel): string
|
||||||
@@ -478,6 +480,7 @@ class OrdersController
|
|||||||
'mini_program' => '小程序',
|
'mini_program' => '小程序',
|
||||||
'h5' => 'H5',
|
'h5' => 'H5',
|
||||||
'enterprise_push' => '大客户推送订单',
|
'enterprise_push' => '大客户推送订单',
|
||||||
|
'manual_entry' => '后台补录订单',
|
||||||
default => '未知渠道',
|
default => '未知渠道',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,9 +92,8 @@ class ReportsController
|
|||||||
|
|
||||||
$reportData = is_array($report) ? $report : $report->toArray();
|
$reportData = is_array($report) ? $report : $report->toArray();
|
||||||
$content = Db::name('report_contents')->where('report_id', $reportData['id'])->find();
|
$content = Db::name('report_contents')->where('report_id', $reportData['id'])->find();
|
||||||
$isZhongjian = (string)($reportData['service_provider'] ?? '') === 'zhongjian';
|
$verify = Db::name('report_verifies')->where('report_id', $reportData['id'])->find() ?: [];
|
||||||
$verify = $isZhongjian ? [] : (Db::name('report_verifies')->where('report_id', $reportData['id'])->find() ?: []);
|
$verify = $this->normalizeVerifyInfo($reportData, $verify);
|
||||||
$verify = $isZhongjian ? [] : $this->normalizeVerifyInfo($reportData, $verify);
|
|
||||||
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: []);
|
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: []);
|
||||||
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
|
||||||
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
|
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
|
||||||
@@ -130,9 +129,9 @@ class ReportsController
|
|||||||
'risk_notice_text' => $payload['risk_notice_text'],
|
'risk_notice_text' => $payload['risk_notice_text'],
|
||||||
'verify_info' => [
|
'verify_info' => [
|
||||||
'report_no' => $reportData['report_no'],
|
'report_no' => $reportData['report_no'],
|
||||||
'verify_status' => $isZhongjian ? '' : ($verify['verify_status'] ?? 'valid'),
|
'verify_status' => $verify['verify_status'] ?? 'valid',
|
||||||
'verify_url' => $isZhongjian ? '' : ($verify['verify_url'] ?? ''),
|
'verify_url' => $verify['verify_url'] ?? '',
|
||||||
'verify_qrcode_url' => $isZhongjian ? '' : ($verify['verify_qrcode_url'] ?? ''),
|
'verify_qrcode_url' => $verify['verify_qrcode_url'] ?? '',
|
||||||
],
|
],
|
||||||
'file_info' => [
|
'file_info' => [
|
||||||
'pdf_url' => $pdfUrl,
|
'pdf_url' => $pdfUrl,
|
||||||
@@ -218,9 +217,7 @@ class ReportsController
|
|||||||
'verify_info' => sprintf(
|
'verify_info' => sprintf(
|
||||||
'%s / %s',
|
'%s / %s',
|
||||||
$verify['report_no'] ?? ($report['report_no'] ?? '-'),
|
$verify['report_no'] ?? ($report['report_no'] ?? '-'),
|
||||||
($report['service_provider'] ?? '') === 'zhongjian'
|
(($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-'))
|
||||||
? '中检报告'
|
|
||||||
: (($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-'))
|
|
||||||
),
|
),
|
||||||
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : ($defaultRiskNotice !== '' ? $defaultRiskNotice : '-'),
|
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : ($defaultRiskNotice !== '' ? $defaultRiskNotice : '-'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class AdminAuthMiddleware implements MiddlewareInterface
|
|||||||
{
|
{
|
||||||
return match (true) {
|
return match (true) {
|
||||||
str_starts_with($path, '/api/admin/dashboard') => ['dashboard.view'],
|
str_starts_with($path, '/api/admin/dashboard') => ['dashboard.view'],
|
||||||
|
str_starts_with($path, '/api/admin/manual-order/') => ['orders.manage', 'warehouse_workbench.manage'],
|
||||||
str_starts_with($path, '/api/admin/orders') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
|
str_starts_with($path, '/api/admin/orders') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
|
||||||
str_starts_with($path, '/api/admin/order/') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
|
str_starts_with($path, '/api/admin/order/') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
|
||||||
str_starts_with($path, '/api/admin/orders'),
|
str_starts_with($path, '/api/admin/orders'),
|
||||||
|
|||||||
@@ -9,31 +9,21 @@ class FulfillmentFlowService
|
|||||||
{
|
{
|
||||||
public function lookupInboundByTrackingNo(string $trackingNo): array
|
public function lookupInboundByTrackingNo(string $trackingNo): array
|
||||||
{
|
{
|
||||||
$trackingNo = trim($trackingNo);
|
return $this->lookupInboundByInboundNo($trackingNo);
|
||||||
if ($trackingNo === '') {
|
|
||||||
throw new \InvalidArgumentException('请先扫描寄入运单号');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = Db::name('order_logistics')
|
public function lookupInboundByInboundNo(string $inboundNo): array
|
||||||
->where('logistics_type', 'send_to_center')
|
{
|
||||||
->where('tracking_no', $trackingNo)
|
$match = $this->resolveInboundOrder($inboundNo);
|
||||||
->select()
|
|
||||||
->toArray();
|
|
||||||
|
|
||||||
if (!$rows) {
|
return $this->formatOrderContext((int)$match['order_id']);
|
||||||
throw new \RuntimeException('未匹配到订单,请核对寄入运单号', 404);
|
|
||||||
}
|
|
||||||
if (count($rows) > 1) {
|
|
||||||
throw new \RuntimeException('该运单号匹配到多笔订单,请人工核查后处理', 409);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->formatOrderContext((int)$rows[0]['order_id']);
|
public function receiveInbound(string $inboundNo, string $tagNo, Request $request, mixed $attachments = []): array
|
||||||
}
|
|
||||||
|
|
||||||
public function receiveInbound(string $trackingNo, string $tagNo, Request $request): array
|
|
||||||
{
|
{
|
||||||
$operator = $this->operator($request);
|
$operator = $this->operator($request);
|
||||||
$context = $this->lookupInboundByTrackingNo($trackingNo);
|
$match = $this->resolveInboundOrder($inboundNo);
|
||||||
|
$context = $this->formatOrderContext((int)$match['order_id']);
|
||||||
$order = $context['order_info'];
|
$order = $context['order_info'];
|
||||||
$orderId = (int)$order['id'];
|
$orderId = (int)$order['id'];
|
||||||
|
|
||||||
@@ -47,6 +37,12 @@ class FulfillmentFlowService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$inboundAttachments = (new AppraisalEvidenceService())->normalize($attachments, $request, true);
|
||||||
|
$attachmentCount = count($inboundAttachments);
|
||||||
|
$inboundRemark = $this->inboundMatchRemark($match);
|
||||||
|
if ($attachmentCount > 0) {
|
||||||
|
$inboundRemark .= sprintf(',已上传拆包附件 %d 个', $attachmentCount);
|
||||||
|
}
|
||||||
|
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
@@ -114,8 +110,8 @@ class FulfillmentFlowService
|
|||||||
$logistics = Db::name('order_logistics')
|
$logistics = Db::name('order_logistics')
|
||||||
->where('order_id', $orderId)
|
->where('order_id', $orderId)
|
||||||
->where('logistics_type', 'send_to_center')
|
->where('logistics_type', 'send_to_center')
|
||||||
->where('tracking_no', trim($trackingNo))
|
|
||||||
->order('id', 'desc')
|
->order('id', 'desc')
|
||||||
|
->when(($match['match_type'] ?? '') === 'tracking_no', fn ($query) => $query->where('tracking_no', (string)$match['match_no']))
|
||||||
->find();
|
->find();
|
||||||
if ($logistics) {
|
if ($logistics) {
|
||||||
Db::name('order_logistics')->where('id', (int)$logistics['id'])->update([
|
Db::name('order_logistics')->where('id', (int)$logistics['id'])->update([
|
||||||
@@ -140,7 +136,11 @@ class FulfillmentFlowService
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->insertTimeline($orderId, 'inbound_received', '已入仓待检', '仓管扫描寄入运单并完成物品入库。', $operator, $now);
|
$this->insertTimeline($orderId, 'inbound_received', '已入仓待检', '仓管扫描寄入运单并完成物品入库。', $operator, $now);
|
||||||
$this->insertFlowLog($flow, 'inbound_received', '入库完成', '', '', 'warehouse_received', 'warehouse_pending_inspection', $operator, '扫描寄入运单号入库', $now);
|
$this->insertFlowLog($flow, 'inbound_received', '入库完成', '', '', 'warehouse_received', 'warehouse_pending_inspection', $operator, $inboundRemark, $now, [
|
||||||
|
'match_type' => (string)($match['match_type'] ?? ''),
|
||||||
|
'match_no' => (string)($match['match_no'] ?? ''),
|
||||||
|
'inbound_attachments' => $inboundAttachments,
|
||||||
|
]);
|
||||||
$this->insertFlowLog($flow, 'internal_tag_bound', '绑定内部流转挂牌', 'warehouse_received', 'warehouse_pending_inspection', 'warehouse_received', 'warehouse_pending_inspection', $operator, $tagNo, $now);
|
$this->insertFlowLog($flow, 'internal_tag_bound', '绑定内部流转挂牌', 'warehouse_received', 'warehouse_pending_inspection', 'warehouse_received', 'warehouse_pending_inspection', $operator, $tagNo, $now);
|
||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
@@ -149,7 +149,7 @@ class FulfillmentFlowService
|
|||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->formatOrderContext($orderId);
|
return $this->formatOrderContext($orderId, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scanTransferForAppraisal(string $tagNo, Request $request): array
|
public function scanTransferForAppraisal(string $tagNo, Request $request): array
|
||||||
@@ -283,15 +283,10 @@ class FulfillmentFlowService
|
|||||||
|
|
||||||
public function verifyReturnMaterialTag(string $tagNo, string $qrInput, Request $request): array
|
public function verifyReturnMaterialTag(string $tagNo, string $qrInput, Request $request): array
|
||||||
{
|
{
|
||||||
$operator = $this->operator($request);
|
|
||||||
$flow = $this->findActiveFlowByTagNo($tagNo);
|
$flow = $this->findActiveFlowByTagNo($tagNo);
|
||||||
if (!$flow) {
|
if (!$flow) {
|
||||||
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
|
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
|
||||||
}
|
}
|
||||||
if (($flow['service_provider'] ?? '') === 'zhongjian') {
|
|
||||||
throw new \InvalidArgumentException('中检订单不使用平台验真吊牌');
|
|
||||||
}
|
|
||||||
|
|
||||||
$report = $this->latestReport((int)$flow['order_id']);
|
$report = $this->latestReport((int)$flow['order_id']);
|
||||||
if (!$report || ($report['report_status'] ?? '') !== 'published') {
|
if (!$report || ($report['report_status'] ?? '') !== 'published') {
|
||||||
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
|
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
|
||||||
@@ -302,7 +297,13 @@ class FulfillmentFlowService
|
|||||||
throw new \InvalidArgumentException('验真吊牌与当前订单报告不匹配');
|
throw new \InvalidArgumentException('验真吊牌与当前订单报告不匹配');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->markReturnConfirmed($flow, $operator, 'return_tag_verified', '验真吊牌确认', '仓管已扫描验真吊牌并确认报告信息。');
|
return array_merge($this->formatOrderContext((int)$flow['order_id'], $request), [
|
||||||
|
'return_verification' => [
|
||||||
|
'verified' => true,
|
||||||
|
'report_id' => (int)$report['id'],
|
||||||
|
'report_no' => (string)$report['report_no'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function confirmZhongjianReturn(string $tagNo, Request $request): array
|
public function confirmZhongjianReturn(string $tagNo, Request $request): array
|
||||||
@@ -326,7 +327,44 @@ class FulfillmentFlowService
|
|||||||
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '中检报告已确认', '仓管已查看中检报告编号和报告文件。');
|
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '中检报告已确认', '仓管已查看中检报告编号和报告文件。');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request): array
|
public function confirmReturnReport(string $tagNo, int $reportId, Request $request): array
|
||||||
|
{
|
||||||
|
$operator = $this->operator($request);
|
||||||
|
$flow = $this->findActiveFlowByTagNo($tagNo);
|
||||||
|
if (!$flow) {
|
||||||
|
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $this->latestReport((int)$flow['order_id']);
|
||||||
|
if (!$report || ($report['report_status'] ?? '') !== 'published') {
|
||||||
|
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
|
||||||
|
}
|
||||||
|
if ((int)$report['id'] !== $reportId) {
|
||||||
|
throw new \InvalidArgumentException('确认的报告与当前订单报告不匹配');
|
||||||
|
}
|
||||||
|
if ((string)($flow['current_stage'] ?? '') === 'return_confirmed') {
|
||||||
|
return $this->formatOrderContext((int)$flow['order_id'], $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($flow['service_provider'] ?? '') === 'zhongjian') {
|
||||||
|
$content = Db::name('report_contents')->where('report_id', (int)$report['id'])->find();
|
||||||
|
$files = $this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null);
|
||||||
|
if (trim((string)($report['zhongjian_report_no'] ?? '')) === '' || !$files) {
|
||||||
|
throw new \InvalidArgumentException('中检报告未完整录入,不能确认寄回');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '中检报告已确认', '仓管已查看中检报告编号和报告文件。');
|
||||||
|
}
|
||||||
|
|
||||||
|
$boundTag = (new MaterialTagService())->findBoundTagForReport((int)$report['id']);
|
||||||
|
if (!$boundTag) {
|
||||||
|
throw new \InvalidArgumentException('当前报告未绑定验真吊牌,不能确认寄回');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->markReturnConfirmed($flow, $operator, 'return_tag_verified', '验真吊牌确认', '仓管已核对验真吊牌与报告信息。');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request, array $packingAttachments = []): array
|
||||||
{
|
{
|
||||||
$operator = $this->operator($request);
|
$operator = $this->operator($request);
|
||||||
$flow = $this->findActiveFlowByTagNo($tagNo);
|
$flow = $this->findActiveFlowByTagNo($tagNo);
|
||||||
@@ -342,6 +380,12 @@ class FulfillmentFlowService
|
|||||||
if ($expressCompany === '' || $trackingNo === '') {
|
if ($expressCompany === '' || $trackingNo === '') {
|
||||||
throw new \InvalidArgumentException('请填写回寄快递公司和运单号');
|
throw new \InvalidArgumentException('请填写回寄快递公司和运单号');
|
||||||
}
|
}
|
||||||
|
$packingAttachments = $this->normalizeAssetList($packingAttachments, $request);
|
||||||
|
foreach ($packingAttachments as $asset) {
|
||||||
|
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
|
||||||
|
throw new \InvalidArgumentException('打包装箱附件仅支持图片或视频');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$orderId = (int)$flow['order_id'];
|
$orderId = (int)$flow['order_id'];
|
||||||
$order = Db::name('orders')->where('id', $orderId)->find();
|
$order = Db::name('orders')->where('id', $orderId)->find();
|
||||||
@@ -432,7 +476,9 @@ class FulfillmentFlowService
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->insertTimeline($orderId, 'return_shipped', $nodeText, $nodeDesc, $operator, $now);
|
$this->insertTimeline($orderId, 'return_shipped', $nodeText, $nodeDesc, $operator, $now);
|
||||||
$this->insertFlowLog($flow, 'return_shipped', '物品寄回', 'return_confirmed', (string)$flow['current_location'], 'return_shipped', 'ended', $operator, $trackingNo, $now);
|
$this->insertFlowLog($flow, 'return_shipped', '物品寄回', 'return_confirmed', (string)$flow['current_location'], 'return_shipped', 'ended', $operator, $trackingNo, $now, [
|
||||||
|
'packing_attachments' => $packingAttachments,
|
||||||
|
]);
|
||||||
|
|
||||||
(new MessageDispatcher())->sendInboxEvent('return_shipped', [
|
(new MessageDispatcher())->sendInboxEvent('return_shipped', [
|
||||||
'user_id' => (int)($order['user_id'] ?? 0),
|
'user_id' => (int)($order['user_id'] ?? 0),
|
||||||
@@ -547,18 +593,7 @@ class FulfillmentFlowService
|
|||||||
'report_entered_at' => (string)($report['report_entered_at'] ?? ''),
|
'report_entered_at' => (string)($report['report_entered_at'] ?? ''),
|
||||||
'zhongjian_report_files' => $this->normalizeAssetList($this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null), $request),
|
'zhongjian_report_files' => $this->normalizeAssetList($this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null), $request),
|
||||||
] : null,
|
] : null,
|
||||||
'flow_logs' => array_map(fn (array $log) => [
|
'flow_logs' => array_map(fn (array $log) => $this->formatFlowLog($log, $request), $flowLogs),
|
||||||
'id' => (int)$log['id'],
|
|
||||||
'action_code' => (string)$log['action_code'],
|
|
||||||
'action_text' => (string)$log['action_text'],
|
|
||||||
'before_stage' => $this->stageText((string)($log['before_stage'] ?? '')),
|
|
||||||
'before_location' => $this->locationText((string)($log['before_location'] ?? '')),
|
|
||||||
'after_stage' => $this->stageText((string)($log['after_stage'] ?? '')),
|
|
||||||
'after_location' => $this->locationText((string)($log['after_location'] ?? '')),
|
|
||||||
'operator_name' => (string)($log['operator_name'] ?? ''),
|
|
||||||
'remark' => (string)($log['remark'] ?? ''),
|
|
||||||
'created_at' => (string)($log['created_at'] ?? ''),
|
|
||||||
], $flowLogs),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +684,7 @@ class FulfillmentFlowService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function insertFlowLog(array $flow, string $code, string $text, string $beforeStage, string $beforeLocation, string $afterStage, string $afterLocation, array $operator, string $remark, string $now): void
|
private function insertFlowLog(array $flow, string $code, string $text, string $beforeStage, string $beforeLocation, string $afterStage, string $afterLocation, array $operator, string $remark, string $now, ?array $payload = null): void
|
||||||
{
|
{
|
||||||
Db::name('order_transfer_flow_logs')->insert([
|
Db::name('order_transfer_flow_logs')->insert([
|
||||||
'flow_id' => (int)$flow['id'],
|
'flow_id' => (int)$flow['id'],
|
||||||
@@ -665,11 +700,33 @@ class FulfillmentFlowService
|
|||||||
'operator_id' => $operator['id'],
|
'operator_id' => $operator['id'],
|
||||||
'operator_name' => $operator['name'],
|
'operator_name' => $operator['name'],
|
||||||
'remark' => mb_substr($remark, 0, 500),
|
'remark' => mb_substr($remark, 0, 500),
|
||||||
'payload_json' => null,
|
'payload_json' => $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE) : null,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function formatFlowLog(array $log, ?Request $request): array
|
||||||
|
{
|
||||||
|
$payload = $this->decodeJsonObject($log['payload_json'] ?? null);
|
||||||
|
$attachments = $this->normalizeAssetList($this->decodeJsonArray($payload['inbound_attachments'] ?? []), $request);
|
||||||
|
$packingAttachments = $this->normalizeAssetList($this->decodeJsonArray($payload['packing_attachments'] ?? []), $request);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int)$log['id'],
|
||||||
|
'action_code' => (string)$log['action_code'],
|
||||||
|
'action_text' => (string)$log['action_text'],
|
||||||
|
'before_stage' => $this->stageText((string)($log['before_stage'] ?? '')),
|
||||||
|
'before_location' => $this->locationText((string)($log['before_location'] ?? '')),
|
||||||
|
'after_stage' => $this->stageText((string)($log['after_stage'] ?? '')),
|
||||||
|
'after_location' => $this->locationText((string)($log['after_location'] ?? '')),
|
||||||
|
'operator_name' => (string)($log['operator_name'] ?? ''),
|
||||||
|
'remark' => (string)($log['remark'] ?? ''),
|
||||||
|
'created_at' => (string)($log['created_at'] ?? ''),
|
||||||
|
'inbound_attachments' => $attachments,
|
||||||
|
'packing_attachments' => $packingAttachments,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function ensureTransferTag(string $tagNo, array $operator, string $now): array
|
private function ensureTransferTag(string $tagNo, array $operator, string $now): array
|
||||||
{
|
{
|
||||||
$tag = Db::name('internal_transfer_tags')->where('tag_no', $tagNo)->find();
|
$tag = Db::name('internal_transfer_tags')->where('tag_no', $tagNo)->find();
|
||||||
@@ -695,6 +752,89 @@ class FulfillmentFlowService
|
|||||||
return Db::name('internal_transfer_tags')->where('id', $id)->find();
|
return Db::name('internal_transfer_tags')->where('id', $id)->find();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveInboundOrder(string $inboundNo): array
|
||||||
|
{
|
||||||
|
$inboundNo = trim($inboundNo);
|
||||||
|
if ($inboundNo === '') {
|
||||||
|
throw new \InvalidArgumentException('请先扫描快递单号或输入鉴定订单号');
|
||||||
|
}
|
||||||
|
|
||||||
|
$logisticsRows = Db::name('order_logistics')
|
||||||
|
->where('logistics_type', 'send_to_center')
|
||||||
|
->where('tracking_no', $inboundNo)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
if ($logisticsRows) {
|
||||||
|
if (count($logisticsRows) > 1) {
|
||||||
|
throw new \RuntimeException('该快递单号匹配到多笔订单,请人工核查后处理', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'order_id' => (int)$logisticsRows[0]['order_id'],
|
||||||
|
'match_type' => 'tracking_no',
|
||||||
|
'match_no' => $inboundNo,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderRows = Db::name('orders')
|
||||||
|
->where(function ($builder) use ($inboundNo) {
|
||||||
|
$builder->whereRaw(
|
||||||
|
'(order_no = :order_no OR appraisal_no = :appraisal_no)',
|
||||||
|
[
|
||||||
|
'order_no' => $inboundNo,
|
||||||
|
'appraisal_no' => $inboundNo,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->field(['id', 'order_no', 'appraisal_no'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
if ($orderRows) {
|
||||||
|
if (count($orderRows) > 1) {
|
||||||
|
throw new \RuntimeException('该订单号匹配到多笔订单,请人工核查后处理', 409);
|
||||||
|
}
|
||||||
|
$order = $orderRows[0];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'order_id' => (int)$order['id'],
|
||||||
|
'match_type' => $inboundNo === (string)($order['appraisal_no'] ?? '') ? 'appraisal_no' : 'order_no',
|
||||||
|
'match_no' => $inboundNo,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalRows = Db::name('enterprise_customer_order_refs')
|
||||||
|
->where('external_order_no', $inboundNo)
|
||||||
|
->field(['order_id'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
if ($externalRows) {
|
||||||
|
if (count($externalRows) > 1) {
|
||||||
|
throw new \RuntimeException('该外部订单号匹配到多笔订单,请人工核查后处理', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'order_id' => (int)$externalRows[0]['order_id'],
|
||||||
|
'match_type' => 'external_order_no',
|
||||||
|
'match_no' => $inboundNo,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException('未匹配到待入库订单', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inboundMatchRemark(array $match): string
|
||||||
|
{
|
||||||
|
$label = match ((string)($match['match_type'] ?? '')) {
|
||||||
|
'tracking_no' => '快递单号',
|
||||||
|
'appraisal_no' => '鉴定单号',
|
||||||
|
'order_no' => '订单号',
|
||||||
|
'external_order_no' => '外部订单号',
|
||||||
|
default => '入库编号',
|
||||||
|
};
|
||||||
|
|
||||||
|
return sprintf('扫描%s入库:%s', $label, (string)($match['match_no'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
private function findActiveFlowByTagNo(string $tagNo): ?array
|
private function findActiveFlowByTagNo(string $tagNo): ?array
|
||||||
{
|
{
|
||||||
$tagNo = $this->normalizeTagNo($tagNo);
|
$tagNo = $this->normalizeTagNo($tagNo);
|
||||||
@@ -849,6 +989,7 @@ class FulfillmentFlowService
|
|||||||
'mini_program' => '小程序',
|
'mini_program' => '小程序',
|
||||||
'h5' => 'H5',
|
'h5' => 'H5',
|
||||||
'enterprise_push' => '大客户推送订单',
|
'enterprise_push' => '大客户推送订单',
|
||||||
|
'manual_entry' => '后台补录订单',
|
||||||
default => $sourceChannel ?: '未知渠道',
|
default => $sourceChannel ?: '未知渠道',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -865,6 +1006,19 @@ class FulfillmentFlowService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function decodeJsonObject(mixed $value): array
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeAssetList(array $files, ?Request $request): array
|
private function normalizeAssetList(array $files, ?Request $request): array
|
||||||
{
|
{
|
||||||
if (!$request) {
|
if (!$request) {
|
||||||
|
|||||||
@@ -352,9 +352,6 @@ class MaterialTagService
|
|||||||
if (!$task) {
|
if (!$task) {
|
||||||
throw new \RuntimeException('任务不存在', 404);
|
throw new \RuntimeException('任务不存在', 404);
|
||||||
}
|
}
|
||||||
if (($task['service_provider'] ?? '') === 'zhongjian') {
|
|
||||||
throw new \InvalidArgumentException('中检订单不使用平台验真吊牌');
|
|
||||||
}
|
|
||||||
$report = Db::name('reports')
|
$report = Db::name('reports')
|
||||||
->where('order_id', (int)$task['order_id'])
|
->where('order_id', (int)$task['order_id'])
|
||||||
->where('report_type', 'appraisal')
|
->where('report_type', 'appraisal')
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ Route::post('/api/admin/auth/logout', [AdminAuthController::class, 'logout']);
|
|||||||
Route::get('/api/admin/dashboard', [AdminDashboardController::class, 'index']);
|
Route::get('/api/admin/dashboard', [AdminDashboardController::class, 'index']);
|
||||||
Route::get('/api/admin/orders', [AdminOrdersController::class, 'index']);
|
Route::get('/api/admin/orders', [AdminOrdersController::class, 'index']);
|
||||||
Route::get('/api/admin/order/detail', [AdminOrdersController::class, 'detail']);
|
Route::get('/api/admin/order/detail', [AdminOrdersController::class, 'detail']);
|
||||||
|
Route::get('/api/admin/manual-order/meta', [AdminOrdersController::class, 'manualOrderMeta']);
|
||||||
|
Route::post('/api/admin/manual-order/create', [AdminOrdersController::class, 'createManualOrder']);
|
||||||
|
Route::post('/api/admin/manual-order/file/upload', [AdminOrdersController::class, 'uploadManualOrderFile']);
|
||||||
Route::get('/api/admin/order/warehouse/options', [AdminOrdersController::class, 'warehouseOptions']);
|
Route::get('/api/admin/order/warehouse/options', [AdminOrdersController::class, 'warehouseOptions']);
|
||||||
Route::post('/api/admin/order/warehouse/reassign', [AdminOrdersController::class, 'reassignWarehouse']);
|
Route::post('/api/admin/order/warehouse/reassign', [AdminOrdersController::class, 'reassignWarehouse']);
|
||||||
Route::post('/api/admin/order/logistics/receive', [AdminOrdersController::class, 'receiveLogistics']);
|
Route::post('/api/admin/order/logistics/receive', [AdminOrdersController::class, 'receiveLogistics']);
|
||||||
@@ -258,12 +261,15 @@ Route::get('/api/admin/warehouses', [AdminWarehousesController::class, 'index'])
|
|||||||
Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']);
|
Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']);
|
||||||
Route::get('/api/admin/warehouse-workbench/inbound/lookup', [AdminWarehouseWorkbenchController::class, 'inboundLookup']);
|
Route::get('/api/admin/warehouse-workbench/inbound/lookup', [AdminWarehouseWorkbenchController::class, 'inboundLookup']);
|
||||||
Route::post('/api/admin/warehouse-workbench/inbound/receive', [AdminWarehouseWorkbenchController::class, 'inboundReceive']);
|
Route::post('/api/admin/warehouse-workbench/inbound/receive', [AdminWarehouseWorkbenchController::class, 'inboundReceive']);
|
||||||
|
Route::post('/api/admin/warehouse-workbench/inbound/evidence/upload', [AdminWarehouseWorkbenchController::class, 'uploadInboundEvidenceFile']);
|
||||||
|
Route::post('/api/admin/warehouse-workbench/return/packing/upload', [AdminWarehouseWorkbenchController::class, 'uploadReturnPackingFile']);
|
||||||
Route::get('/api/admin/warehouse-workbench/zhongjian/lookup', [AdminWarehouseWorkbenchController::class, 'zhongjianLookup']);
|
Route::get('/api/admin/warehouse-workbench/zhongjian/lookup', [AdminWarehouseWorkbenchController::class, 'zhongjianLookup']);
|
||||||
Route::post('/api/admin/warehouse-workbench/zhongjian/outbound', [AdminWarehouseWorkbenchController::class, 'zhongjianOutbound']);
|
Route::post('/api/admin/warehouse-workbench/zhongjian/outbound', [AdminWarehouseWorkbenchController::class, 'zhongjianOutbound']);
|
||||||
Route::post('/api/admin/warehouse-workbench/zhongjian/inbound', [AdminWarehouseWorkbenchController::class, 'zhongjianInbound']);
|
Route::post('/api/admin/warehouse-workbench/zhongjian/inbound', [AdminWarehouseWorkbenchController::class, 'zhongjianInbound']);
|
||||||
Route::get('/api/admin/warehouse-workbench/return/lookup', [AdminWarehouseWorkbenchController::class, 'returnLookup']);
|
Route::get('/api/admin/warehouse-workbench/return/lookup', [AdminWarehouseWorkbenchController::class, 'returnLookup']);
|
||||||
Route::post('/api/admin/warehouse-workbench/return/material-tag/verify', [AdminWarehouseWorkbenchController::class, 'verifyReturnMaterialTag']);
|
Route::post('/api/admin/warehouse-workbench/return/material-tag/verify', [AdminWarehouseWorkbenchController::class, 'verifyReturnMaterialTag']);
|
||||||
Route::post('/api/admin/warehouse-workbench/return/zhongjian/confirm', [AdminWarehouseWorkbenchController::class, 'confirmZhongjianReturn']);
|
Route::post('/api/admin/warehouse-workbench/return/zhongjian/confirm', [AdminWarehouseWorkbenchController::class, 'confirmZhongjianReturn']);
|
||||||
|
Route::post('/api/admin/warehouse-workbench/return/report/confirm', [AdminWarehouseWorkbenchController::class, 'confirmReturnReport']);
|
||||||
Route::post('/api/admin/warehouse-workbench/return/ship', [AdminWarehouseWorkbenchController::class, 'shipReturn']);
|
Route::post('/api/admin/warehouse-workbench/return/ship', [AdminWarehouseWorkbenchController::class, 'shipReturn']);
|
||||||
Route::get('/api/admin/material/batches', [AdminMaterialsController::class, 'batches']);
|
Route::get('/api/admin/material/batches', [AdminMaterialsController::class, 'batches']);
|
||||||
Route::get('/api/admin/material/batch/detail', [AdminMaterialsController::class, 'detail']);
|
Route::get('/api/admin/material/batch/detail', [AdminMaterialsController::class, 'detail']);
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ const zhongjianReportOtherAttachments = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
function goVerify() {
|
function goVerify() {
|
||||||
if (isZhongjianReport.value) {
|
|
||||||
uni.showToast({ title: "中检报告不使用平台验真吊牌", icon: "none" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uni.navigateTo({ url: `/pages/verify/result?report_no=${detail.value.report_header.report_no}` });
|
uni.navigateTo({ url: `/pages/verify/result?report_no=${detail.value.report_header.report_no}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +262,7 @@ onLoad(async (options) => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="!isZhongjianReport" class="section section-card">
|
<view class="section section-card">
|
||||||
<view class="section__title">报告凭证</view>
|
<view class="section__title">报告凭证</view>
|
||||||
<view class="credential-box">
|
<view class="credential-box">
|
||||||
<view class="credential-box__qr">
|
<view class="credential-box__qr">
|
||||||
@@ -285,7 +281,7 @@ onLoad(async (options) => {
|
|||||||
|
|
||||||
<view v-if="isZhongjianReport" class="section section-card">
|
<view v-if="isZhongjianReport" class="section section-card">
|
||||||
<view class="section__title">中检报告文件</view>
|
<view class="section__title">中检报告文件</view>
|
||||||
<view class="section__desc">中检订单不使用平台验真吊牌,请以中检报告编号与报告文件为准。</view>
|
<view class="section__desc">中检报告文件可在下方查看,报告验真请以报告凭证与吊牌组合验真结果为准。</view>
|
||||||
|
|
||||||
<view v-if="zhongjianReportImageAttachments.length" class="task-files" style="margin-top: 20rpx;">
|
<view v-if="zhongjianReportImageAttachments.length" class="task-files" style="margin-top: 20rpx;">
|
||||||
<view
|
<view
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface AdminOrderListItem {
|
|||||||
source_customer_id: string;
|
source_customer_id: string;
|
||||||
order_status: string;
|
order_status: string;
|
||||||
display_status: string;
|
display_status: string;
|
||||||
|
internal_tag_no?: string;
|
||||||
warehouse_bucket?: string;
|
warehouse_bucket?: string;
|
||||||
warehouse_bucket_text?: string;
|
warehouse_bucket_text?: string;
|
||||||
estimated_finish_time: string;
|
estimated_finish_time: string;
|
||||||
@@ -43,6 +44,66 @@ export interface AdminOrderListItem {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminManualOrderMaterialItem {
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
is_required: boolean;
|
||||||
|
files: AdminFileAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminManualOrderCreatePayload {
|
||||||
|
service_provider: string;
|
||||||
|
product_info: {
|
||||||
|
category_id: number;
|
||||||
|
brand_id: number;
|
||||||
|
product_name: string;
|
||||||
|
color: string;
|
||||||
|
size_spec: string;
|
||||||
|
serial_no: string;
|
||||||
|
};
|
||||||
|
extra_info: {
|
||||||
|
purchase_channel: string;
|
||||||
|
purchase_price: number;
|
||||||
|
usage_status: string;
|
||||||
|
condition_desc: string;
|
||||||
|
remark: string;
|
||||||
|
};
|
||||||
|
return_address: {
|
||||||
|
consignee: string;
|
||||||
|
mobile: string;
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
detail_address: string;
|
||||||
|
};
|
||||||
|
materials: AdminManualOrderMaterialItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminManualOrderCreateResponse {
|
||||||
|
order_id: number;
|
||||||
|
order_no: string;
|
||||||
|
appraisal_no: string;
|
||||||
|
user_id: number;
|
||||||
|
next_status: "pending_shipping";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminManualOrderMeta {
|
||||||
|
categories: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
supported_service_types: string[];
|
||||||
|
}>;
|
||||||
|
brands: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
en_name: string;
|
||||||
|
code: string;
|
||||||
|
category_ids: number[];
|
||||||
|
supported_service_types: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminOrderDetail {
|
export interface AdminOrderDetail {
|
||||||
order_info: AdminOrderListItem & {
|
order_info: AdminOrderListItem & {
|
||||||
can_mark_received: boolean;
|
can_mark_received: boolean;
|
||||||
@@ -65,6 +126,10 @@ export interface AdminOrderDetail {
|
|||||||
full_address: string;
|
full_address: string;
|
||||||
};
|
};
|
||||||
logistics_info: null | Record<string, any>;
|
logistics_info: null | Record<string, any>;
|
||||||
|
inbound_attachments: AdminFileAsset[];
|
||||||
|
transfer_flow: null | {
|
||||||
|
internal_tag_no: string;
|
||||||
|
};
|
||||||
return_logistics: null | Record<string, any>;
|
return_logistics: null | Record<string, any>;
|
||||||
supplement_task: null | Record<string, any>;
|
supplement_task: null | Record<string, any>;
|
||||||
report_summary: null | {
|
report_summary: null | {
|
||||||
@@ -119,12 +184,15 @@ export interface AdminWarehouseWorkbenchContext {
|
|||||||
tracking_status: string;
|
tracking_status: string;
|
||||||
};
|
};
|
||||||
transfer_flow: null | {
|
transfer_flow: null | {
|
||||||
|
id?: number;
|
||||||
internal_tag_no: string;
|
internal_tag_no: string;
|
||||||
|
flow_status?: string;
|
||||||
current_stage: string;
|
current_stage: string;
|
||||||
current_stage_text: string;
|
current_stage_text: string;
|
||||||
current_location: string;
|
current_location: string;
|
||||||
current_location_text: string;
|
current_location_text: string;
|
||||||
return_confirmed_at?: string;
|
return_confirmed_at?: string;
|
||||||
|
return_shipped_at?: string;
|
||||||
};
|
};
|
||||||
report_info: null | {
|
report_info: null | {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -140,7 +208,14 @@ export interface AdminWarehouseWorkbenchContext {
|
|||||||
operator_name: string;
|
operator_name: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
inbound_attachments?: AdminFileAsset[];
|
||||||
|
packing_attachments?: AdminFileAsset[];
|
||||||
}>;
|
}>;
|
||||||
|
return_verification?: {
|
||||||
|
verified: boolean;
|
||||||
|
report_id: number;
|
||||||
|
report_no: string;
|
||||||
|
};
|
||||||
next_action?: string;
|
next_action?: string;
|
||||||
next_action_text?: string;
|
next_action_text?: string;
|
||||||
}
|
}
|
||||||
@@ -168,6 +243,7 @@ export interface AdminAppraisalTaskListItem {
|
|||||||
sla_deadline: string;
|
sla_deadline: string;
|
||||||
is_overtime: boolean;
|
is_overtime: boolean;
|
||||||
display_status: string;
|
display_status: string;
|
||||||
|
internal_tag_no?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAppraisalTaskDetail {
|
export interface AdminAppraisalTaskDetail {
|
||||||
@@ -345,17 +421,32 @@ export const adminApi = {
|
|||||||
getOrderDetail(id: number) {
|
getOrderDetail(id: number) {
|
||||||
return request<AdminOrderDetail>("/api/admin/order/detail", { params: { id } });
|
return request<AdminOrderDetail>("/api/admin/order/detail", { params: { id } });
|
||||||
},
|
},
|
||||||
lookupWarehouseInbound(trackingNo: string) {
|
getManualOrderMeta() {
|
||||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/lookup", {
|
return request<AdminManualOrderMeta>("/api/admin/manual-order/meta");
|
||||||
params: { tracking_no: trackingNo },
|
},
|
||||||
|
createManualOrder(data: AdminManualOrderCreatePayload) {
|
||||||
|
return request<AdminManualOrderCreateResponse>("/api/admin/manual-order/create", {
|
||||||
|
method: "POST",
|
||||||
|
data: data as unknown as Record<string, unknown>,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
receiveWarehouseInbound(data: { tracking_no: string; internal_tag_no: string }) {
|
uploadManualOrderFile(filePath: string) {
|
||||||
|
return uploadFile<AdminFileAsset>("/api/admin/manual-order/file/upload", filePath);
|
||||||
|
},
|
||||||
|
lookupWarehouseInbound(inboundNo: string) {
|
||||||
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/lookup", {
|
||||||
|
params: { inbound_no: inboundNo },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
receiveWarehouseInbound(data: { inbound_no: string; internal_tag_no: string; inbound_attachments?: AdminFileAsset[] }) {
|
||||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/receive", {
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/inbound/receive", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
uploadWarehouseInboundEvidenceFile(filePath: string) {
|
||||||
|
return uploadFile<AdminFileAsset>("/api/admin/warehouse-workbench/inbound/evidence/upload", filePath);
|
||||||
|
},
|
||||||
lookupZhongjianWarehouseTransfer(internalTagNo: string) {
|
lookupZhongjianWarehouseTransfer(internalTagNo: string) {
|
||||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/lookup", {
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/zhongjian/lookup", {
|
||||||
params: { internal_tag_no: internalTagNo },
|
params: { internal_tag_no: internalTagNo },
|
||||||
@@ -384,13 +475,22 @@ export const adminApi = {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
confirmWarehouseReturnReport(data: { internal_tag_no: string; report_id: number }) {
|
||||||
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/report/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
},
|
||||||
confirmWarehouseReturnZhongjian(internalTagNo: string) {
|
confirmWarehouseReturnZhongjian(internalTagNo: string) {
|
||||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/zhongjian/confirm", {
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/zhongjian/confirm", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: { internal_tag_no: internalTagNo },
|
data: { internal_tag_no: internalTagNo },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string }) {
|
uploadWarehouseReturnPackingFile(filePath: string) {
|
||||||
|
return uploadFile<AdminFileAsset>("/api/admin/warehouse-workbench/return/packing/upload", filePath);
|
||||||
|
},
|
||||||
|
shipWarehouseReturn(data: { internal_tag_no: string; express_company: string; tracking_no: string; packing_attachments?: AdminFileAsset[] }) {
|
||||||
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/ship", {
|
return request<AdminWarehouseWorkbenchContext>("/api/admin/warehouse-workbench/return/ship", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data,
|
data,
|
||||||
@@ -417,7 +517,7 @@ export const adminApi = {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[] }) {
|
saveZhongjianAppraisalReport(data: { id: number; zhongjian_report_no: string; report_files: AdminFileAsset[]; qr_input: string }) {
|
||||||
return request<{ id: number; report: Record<string, any> }>("/api/admin/appraisal-task/zhongjian-report/save", {
|
return request<{ id: number; report: Record<string, any> }>("/api/admin/appraisal-task/zhongjian-report/save", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data,
|
data,
|
||||||
@@ -429,13 +529,13 @@ export const adminApi = {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
uploadAppraisalEvidenceFile(filePath: string) {
|
uploadAppraisalEvidenceFile(filePath: string, taskId?: number) {
|
||||||
return uploadFile<AdminFileAsset>("/api/admin/appraisal-task/evidence/upload", filePath);
|
return uploadFile<AdminFileAsset>("/api/admin/appraisal-task/evidence/upload", filePath, taskId ? { task_id: taskId } : {});
|
||||||
},
|
},
|
||||||
deleteAppraisalEvidenceFile(fileUrl: string) {
|
deleteAppraisalEvidenceFile(fileUrl: string, taskId?: number) {
|
||||||
return request<{ file_url: string }>("/api/admin/appraisal-task/evidence/delete", {
|
return request<{ file_url: string }>("/api/admin/appraisal-task/evidence/delete", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: { file_url: fileUrl },
|
data: { file_url: fileUrl, ...(taskId ? { task_id: taskId } : {}) },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getReports(params?: Record<string, string | number>) {
|
getReports(params?: Record<string, string | number>) {
|
||||||
|
|||||||
@@ -36,11 +36,23 @@
|
|||||||
"navigationBarTitleText": "订单详情"
|
"navigationBarTitleText": "订单详情"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/order/manual-create",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "补录订单"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/report/detail",
|
"path": "pages/report/detail",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "报告详情"
|
"navigationBarTitleText": "报告详情"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/return-shipping/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "确认回寄"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tabBar": {
|
"tabBar": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||||
import { adminApi, type AdminOrderDetail } from "../../api/admin";
|
import { adminApi, type AdminFileAsset, type AdminOrderDetail } from "../../api/admin";
|
||||||
import { showErrorToast } from "../../utils/feedback";
|
import { showErrorToast } from "../../utils/feedback";
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -9,10 +9,13 @@ const pageReady = ref(false);
|
|||||||
const loadError = ref("");
|
const loadError = ref("");
|
||||||
const detail = ref<AdminOrderDetail | null>(null);
|
const detail = ref<AdminOrderDetail | null>(null);
|
||||||
const orderId = ref(0);
|
const orderId = ref(0);
|
||||||
|
const activeInboundVideo = ref<AdminFileAsset | null>(null);
|
||||||
|
|
||||||
const pageTitle = computed(() => detail.value?.order_info.order_no || "订单详情");
|
const pageTitle = computed(() => detail.value?.order_info.order_no || "订单详情");
|
||||||
|
|
||||||
const timeline = computed(() => detail.value?.timeline || []);
|
const timeline = computed(() => detail.value?.timeline || []);
|
||||||
|
const inboundAttachments = computed(() => detail.value?.inbound_attachments || []);
|
||||||
|
const internalTagNo = computed(() => detail.value?.transfer_flow?.internal_tag_no || detail.value?.order_info.internal_tag_no || "");
|
||||||
|
|
||||||
async function fetchDetail() {
|
async function fetchDetail() {
|
||||||
if (!orderId.value) return;
|
if (!orderId.value) return;
|
||||||
@@ -47,6 +50,39 @@ function displayAddress(address?: { consignee?: string; mobile?: string; full_ad
|
|||||||
return [address.consignee, address.mobile, address.full_address].filter(Boolean).join(" / ");
|
return [address.consignee, address.mobile, address.full_address].filter(Boolean).join(" / ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isImageAsset(item: AdminFileAsset) {
|
||||||
|
return item.file_type === "image" || item.mime_type?.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoAsset(item: AdminFileAsset) {
|
||||||
|
return item.file_type === "video" || item.mime_type?.startsWith("video/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentTypeLabel(item: AdminFileAsset) {
|
||||||
|
if (isImageAsset(item)) return "图片";
|
||||||
|
if (isVideoAsset(item)) return "视频";
|
||||||
|
return "附件";
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewInboundAttachment(item: AdminFileAsset) {
|
||||||
|
if (isImageAsset(item)) {
|
||||||
|
const urls = inboundAttachments.value.filter(isImageAsset).map((asset) => asset.file_url);
|
||||||
|
uni.previewImage({ urls, current: item.file_url });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVideoAsset(item)) {
|
||||||
|
activeInboundVideo.value = item;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showToast({ title: "当前附件暂不支持预览", icon: "none" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInboundVideo() {
|
||||||
|
activeInboundVideo.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
orderId.value = Number(options?.id || 0);
|
orderId.value = Number(options?.id || 0);
|
||||||
if (!orderId.value) {
|
if (!orderId.value) {
|
||||||
@@ -98,6 +134,10 @@ onShow(() => {
|
|||||||
<view class="meta-label">预计完成</view>
|
<view class="meta-label">预计完成</view>
|
||||||
<view class="meta-value">{{ detail.order_info.estimated_finish_time || "-" }}</view>
|
<view class="meta-value">{{ detail.order_info.estimated_finish_time || "-" }}</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="internalTagNo" class="meta-item meta-item--wide">
|
||||||
|
<view class="meta-label">流转码编号</view>
|
||||||
|
<view class="meta-value transfer-code-value">{{ internalTagNo }}</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -141,6 +181,39 @@ onShow(() => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="inboundAttachments.length" class="card">
|
||||||
|
<view class="row attachment-card-head">
|
||||||
|
<view class="attachment-card-copy">
|
||||||
|
<view class="card-title">入库附件</view>
|
||||||
|
<view class="card-desc">仓管入库时提交的拆包图片和视频,可点击预览。</view>
|
||||||
|
</view>
|
||||||
|
<text class="tag attachment-count">{{ inboundAttachments.length }}个</text>
|
||||||
|
</view>
|
||||||
|
<view class="attachment-grid">
|
||||||
|
<view v-for="item in inboundAttachments" :key="item.file_url" class="attachment-tile">
|
||||||
|
<view class="attachment-preview" @click="previewInboundAttachment(item)">
|
||||||
|
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||||
|
<video
|
||||||
|
v-else-if="isVideoAsset(item)"
|
||||||
|
class="attachment-thumb attachment-video-thumb"
|
||||||
|
:src="item.file_url"
|
||||||
|
:controls="false"
|
||||||
|
:muted="true"
|
||||||
|
:show-center-play-btn="false"
|
||||||
|
:enable-progress-gesture="false"
|
||||||
|
object-fit="cover"
|
||||||
|
/>
|
||||||
|
<view v-else class="attachment-file-thumb">附件</view>
|
||||||
|
<view v-if="isVideoAsset(item)" class="attachment-play">▶</view>
|
||||||
|
</view>
|
||||||
|
<view class="attachment-meta">
|
||||||
|
<text class="attachment-name">{{ item.name || item.file_id }}</text>
|
||||||
|
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view v-if="detail.report_summary" class="card">
|
<view v-if="detail.report_summary" class="card">
|
||||||
<view class="row">
|
<view class="row">
|
||||||
<view>
|
<view>
|
||||||
@@ -177,6 +250,16 @@ onShow(() => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="activeInboundVideo" class="video-preview-mask" @click="closeInboundVideo">
|
||||||
|
<view class="video-preview-panel" @click.stop>
|
||||||
|
<view class="video-preview-head">
|
||||||
|
<text class="video-preview-title">{{ activeInboundVideo.name || "入库视频" }}</text>
|
||||||
|
<text class="video-preview-close" @click="closeInboundVideo">关闭</text>
|
||||||
|
</view>
|
||||||
|
<video class="video-preview-player" :src="activeInboundVideo.file_url" controls autoplay />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -212,4 +295,166 @@ onShow(() => {
|
|||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachment-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-code-value {
|
||||||
|
color: var(--work-warning);
|
||||||
|
font-weight: 900;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card-copy {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-tile {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumb {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-video-thumb {
|
||||||
|
background: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file-thumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-play {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 54rpx;
|
||||||
|
height: 54rpx;
|
||||||
|
margin-left: -27rpx;
|
||||||
|
margin-top: -27rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(32, 33, 36, 0.72);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 54rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-type {
|
||||||
|
min-height: 34rpx;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
border-radius: var(--work-radius-pill);
|
||||||
|
background: var(--work-info-soft);
|
||||||
|
color: var(--work-info);
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-panel {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--work-radius);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
padding: 22rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--work-info);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-player {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 58vh;
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
414
work-app/src/pages/order/manual-create.vue
Normal file
414
work-app/src/pages/order/manual-create.vue
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import { adminApi, type AdminFileAsset, type AdminManualOrderCreatePayload, type AdminManualOrderMeta } from "../../api/admin";
|
||||||
|
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||||
|
import { buildRegionPickerState, updateRegionPickerIndexes } from "../../utils/regions";
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const meta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
|
||||||
|
const form = ref<AdminManualOrderCreatePayload>(createForm());
|
||||||
|
const purchasePriceInput = ref("");
|
||||||
|
const regionPickerIndexes = ref<[number, number, number]>([0, 0, 0]);
|
||||||
|
|
||||||
|
const providerOptions = [
|
||||||
|
{ label: "实物鉴定", value: "anxinyan" },
|
||||||
|
{ label: "中检鉴定", value: "zhongjian" },
|
||||||
|
];
|
||||||
|
const usageOptions = [
|
||||||
|
{ label: "未选择", value: "" },
|
||||||
|
{ label: "全新未使用", value: "new" },
|
||||||
|
{ label: "轻微使用痕迹", value: "light_use" },
|
||||||
|
{ label: "长期使用", value: "used" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const providerIndex = computed(() => Math.max(0, providerOptions.findIndex((item) => item.value === form.value.service_provider)));
|
||||||
|
const categoryIndex = computed(() => Math.max(0, meta.value.categories.findIndex((item) => item.id === form.value.product_info.category_id)));
|
||||||
|
const brandOptions = computed(() => {
|
||||||
|
const categoryId = form.value.product_info.category_id;
|
||||||
|
const provider = form.value.service_provider;
|
||||||
|
return meta.value.brands.filter((item) => {
|
||||||
|
const categoryMatched = !categoryId || !item.category_ids.length || item.category_ids.includes(categoryId);
|
||||||
|
const providerMatched = !item.supported_service_types.length || item.supported_service_types.includes(provider);
|
||||||
|
return categoryMatched && providerMatched;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const brandIndex = computed(() => Math.max(0, brandOptions.value.findIndex((item) => item.id === form.value.product_info.brand_id)));
|
||||||
|
const usageIndex = computed(() => Math.max(0, usageOptions.findIndex((item) => item.value === form.value.extra_info.usage_status)));
|
||||||
|
const materialFiles = computed(() => form.value.materials[0].files);
|
||||||
|
const selectedRegionText = computed(() => {
|
||||||
|
const { province, city, district } = form.value.return_address;
|
||||||
|
return province && city && district ? `${province} / ${city} / ${district}` : "";
|
||||||
|
});
|
||||||
|
const regionPickerState = computed(() => buildRegionPickerState(regionPickerIndexes.value));
|
||||||
|
|
||||||
|
function createForm(): AdminManualOrderCreatePayload {
|
||||||
|
return {
|
||||||
|
service_provider: "anxinyan",
|
||||||
|
product_info: {
|
||||||
|
category_id: 0,
|
||||||
|
brand_id: 0,
|
||||||
|
product_name: "",
|
||||||
|
color: "",
|
||||||
|
size_spec: "",
|
||||||
|
serial_no: "",
|
||||||
|
},
|
||||||
|
extra_info: {
|
||||||
|
purchase_channel: "",
|
||||||
|
purchase_price: 0,
|
||||||
|
usage_status: "",
|
||||||
|
condition_desc: "",
|
||||||
|
remark: "",
|
||||||
|
},
|
||||||
|
return_address: {
|
||||||
|
consignee: "",
|
||||||
|
mobile: "",
|
||||||
|
province: "",
|
||||||
|
city: "",
|
||||||
|
district: "",
|
||||||
|
detail_address: "",
|
||||||
|
},
|
||||||
|
materials: [
|
||||||
|
{
|
||||||
|
item_code: "manual_initial",
|
||||||
|
item_name: "补录资料",
|
||||||
|
is_required: false,
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMeta() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
meta.value = await adminApi.getManualOrderMeta();
|
||||||
|
if (!form.value.product_info.category_id && meta.value.categories.length) {
|
||||||
|
form.value.product_info.category_id = meta.value.categories[0].id;
|
||||||
|
}
|
||||||
|
if (!form.value.product_info.brand_id && brandOptions.value.length) {
|
||||||
|
form.value.product_info.brand_id = brandOptions.value[0].id;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "补录选项加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProviderChange(event: any) {
|
||||||
|
const index = Number(event.detail?.value || 0);
|
||||||
|
form.value.service_provider = providerOptions[index]?.value || "anxinyan";
|
||||||
|
ensureBrandSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCategoryChange(event: any) {
|
||||||
|
const index = Number(event.detail?.value || 0);
|
||||||
|
form.value.product_info.category_id = meta.value.categories[index]?.id || 0;
|
||||||
|
ensureBrandSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBrandChange(event: any) {
|
||||||
|
const index = Number(event.detail?.value || 0);
|
||||||
|
form.value.product_info.brand_id = brandOptions.value[index]?.id || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUsageChange(event: any) {
|
||||||
|
const index = Number(event.detail?.value || 0);
|
||||||
|
form.value.extra_info.usage_status = usageOptions[index]?.value || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPurchasePriceInput(event: any) {
|
||||||
|
purchasePriceInput.value = String(event.detail?.value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRegionSelection(selection: string[]) {
|
||||||
|
const [province = "", city = "", district = ""] = selection;
|
||||||
|
form.value.return_address.province = province;
|
||||||
|
form.value.return_address.city = city;
|
||||||
|
form.value.return_address.district = district;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRegionColumnChange(event: any) {
|
||||||
|
regionPickerIndexes.value = updateRegionPickerIndexes(regionPickerState.value.indexes, {
|
||||||
|
column: event?.detail?.column || 0,
|
||||||
|
value: event?.detail?.value || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRegionChange(event: any) {
|
||||||
|
const indexes = event?.detail?.value || regionPickerState.value.indexes;
|
||||||
|
regionPickerIndexes.value = indexes;
|
||||||
|
applyRegionSelection(buildRegionPickerState(indexes).selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBrandSelection() {
|
||||||
|
const current = brandOptions.value.find((item) => item.id === form.value.product_info.brand_id);
|
||||||
|
form.value.product_info.brand_id = current?.id || brandOptions.value[0]?.id || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerText(options: Array<{ label?: string; name?: string }>, index: number, fallback: string) {
|
||||||
|
const item = options[index];
|
||||||
|
return item?.label || item?.name || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
const product = form.value.product_info;
|
||||||
|
const address = form.value.return_address;
|
||||||
|
if (!product.category_id || !product.brand_id || !product.product_name.trim()) {
|
||||||
|
showInfoToast("请完整填写品类、品牌和商品名称");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!address.consignee.trim() || !address.mobile.trim() || !address.province.trim() || !address.city.trim() || !address.district.trim() || !address.detail_address.trim()) {
|
||||||
|
showInfoToast("请完整填写寄回信息");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseImageFiles() {
|
||||||
|
try {
|
||||||
|
const result = await uni.chooseImage({
|
||||||
|
count: 9,
|
||||||
|
sizeType: ["compressed"],
|
||||||
|
sourceType: ["album", "camera"],
|
||||||
|
});
|
||||||
|
if (!result.tempFilePaths?.length) return;
|
||||||
|
uploading.value = true;
|
||||||
|
for (const filePath of result.tempFilePaths) {
|
||||||
|
const asset = await adminApi.uploadManualOrderFile(filePath);
|
||||||
|
form.value.materials[0].files.push(asset);
|
||||||
|
}
|
||||||
|
showInfoToast("图片上传成功");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "图片上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseVideoFile() {
|
||||||
|
try {
|
||||||
|
const result = await uni.chooseVideo({ sourceType: ["album", "camera"] });
|
||||||
|
if (!result.tempFilePath) return;
|
||||||
|
uploading.value = true;
|
||||||
|
const asset = await adminApi.uploadManualOrderFile(result.tempFilePath);
|
||||||
|
form.value.materials[0].files.push(asset);
|
||||||
|
showInfoToast("视频上传成功");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "视频上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(file: AdminFileAsset) {
|
||||||
|
form.value.materials[0].files = form.value.materials[0].files.filter((item) => item.file_url !== file.file_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitManualOrder() {
|
||||||
|
if (!validateForm() || submitting.value || uploading.value) return;
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(JSON.stringify(form.value)) as AdminManualOrderCreatePayload;
|
||||||
|
const purchasePrice = Number(purchasePriceInput.value.trim());
|
||||||
|
payload.extra_info.purchase_price = Number.isFinite(purchasePrice) && purchasePrice > 0 ? purchasePrice : 0;
|
||||||
|
const response = await withLoading("正在创建", () => adminApi.createManualOrder(payload));
|
||||||
|
showInfoToast(`已创建 ${response.order_no}`);
|
||||||
|
uni.redirectTo({ url: `/pages/order/detail?id=${response.order_id}` });
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "补录订单创建失败");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
void fetchMeta();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="eyebrow">仓管补录</view>
|
||||||
|
<view class="title">补录订单</view>
|
||||||
|
<view class="subtitle">创建后等待入库,可用订单号或鉴定单号绑定内部流转挂牌。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading" class="empty">正在加载</view>
|
||||||
|
<template v-else>
|
||||||
|
<view class="card stack">
|
||||||
|
<view class="card-title">订单与商品</view>
|
||||||
|
<picker :range="providerOptions" range-key="label" :value="providerIndex" @change="onProviderChange">
|
||||||
|
<view class="field picker-field">{{ pickerText(providerOptions, providerIndex, "选择服务类型") }}</view>
|
||||||
|
</picker>
|
||||||
|
<picker :range="meta.categories" range-key="name" :value="categoryIndex" @change="onCategoryChange">
|
||||||
|
<view class="field picker-field">{{ pickerText(meta.categories, categoryIndex, "选择品类") }}</view>
|
||||||
|
</picker>
|
||||||
|
<picker :range="brandOptions" range-key="name" :value="brandIndex" @change="onBrandChange">
|
||||||
|
<view class="field picker-field">{{ pickerText(brandOptions, brandIndex, "选择品牌") }}</view>
|
||||||
|
</picker>
|
||||||
|
<input v-model="form.product_info.product_name" class="field" placeholder="商品名称" />
|
||||||
|
<input v-model="form.product_info.color" class="field" placeholder="颜色,可选" />
|
||||||
|
<input v-model="form.product_info.size_spec" class="field" placeholder="规格 / 尺寸,可选" />
|
||||||
|
<input v-model="form.product_info.serial_no" class="field" placeholder="序列号,可选" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card stack">
|
||||||
|
<view class="card-title">补充信息</view>
|
||||||
|
<view class="field-group">
|
||||||
|
<view class="field-label">购买渠道</view>
|
||||||
|
<input v-model="form.extra_info.purchase_channel" class="field" placeholder="请输入购买渠道,可选" />
|
||||||
|
</view>
|
||||||
|
<view class="field-group">
|
||||||
|
<view class="field-label">购买价格</view>
|
||||||
|
<input :value="purchasePriceInput" class="field" type="digit" placeholder="请输入购买价格,可选" @input="onPurchasePriceInput" />
|
||||||
|
</view>
|
||||||
|
<view class="field-group">
|
||||||
|
<view class="field-label">使用情况</view>
|
||||||
|
<picker :range="usageOptions" range-key="label" :value="usageIndex" @change="onUsageChange">
|
||||||
|
<view class="field picker-field">{{ pickerText(usageOptions, usageIndex, "请选择使用情况,可选") }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="field-group">
|
||||||
|
<view class="field-label">成色说明</view>
|
||||||
|
<textarea v-model="form.extra_info.condition_desc" class="textarea" placeholder="请输入成色说明,可选" />
|
||||||
|
</view>
|
||||||
|
<view class="field-group">
|
||||||
|
<view class="field-label">内部备注</view>
|
||||||
|
<textarea v-model="form.extra_info.remark" class="textarea" placeholder="请输入内部备注,可选" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card stack">
|
||||||
|
<view class="card-title">寄回信息</view>
|
||||||
|
<input v-model="form.return_address.consignee" class="field" placeholder="收件人" />
|
||||||
|
<input v-model="form.return_address.mobile" class="field" type="number" placeholder="手机号,用于匹配用户" />
|
||||||
|
<picker
|
||||||
|
mode="multiSelector"
|
||||||
|
:range="regionPickerState.columns"
|
||||||
|
:value="regionPickerState.indexes"
|
||||||
|
@columnchange="onRegionColumnChange"
|
||||||
|
@change="onRegionChange"
|
||||||
|
>
|
||||||
|
<view class="field picker-field region-field">
|
||||||
|
<text v-if="selectedRegionText" class="region-field__value">{{ selectedRegionText }}</text>
|
||||||
|
<text v-else class="region-field__placeholder">请选择省 / 市 / 区县</text>
|
||||||
|
<text class="region-field__arrow"></text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<input v-model="form.return_address.detail_address" class="field" placeholder="详细地址" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card stack">
|
||||||
|
<view class="row">
|
||||||
|
<view>
|
||||||
|
<view class="card-title">初始资料</view>
|
||||||
|
<view class="card-desc">{{ materialFiles.length }} 个文件</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="upload-actions">
|
||||||
|
<button class="btn btn--ghost" :disabled="uploading" @click="chooseImageFiles">{{ uploading ? "上传中" : "添加图片" }}</button>
|
||||||
|
<button class="btn btn--ghost" :disabled="uploading" @click="chooseVideoFile">{{ uploading ? "上传中" : "添加视频" }}</button>
|
||||||
|
</view>
|
||||||
|
<view v-if="materialFiles.length" class="file-list">
|
||||||
|
<view v-for="file in materialFiles" :key="file.file_url" class="file-item">
|
||||||
|
<text class="file-name">{{ file.name || file.file_id }}</text>
|
||||||
|
<text class="tag tag--danger" @click="removeFile(file)">移除</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="btn btn--primary submit-button" :disabled="submitting || uploading" @click="submitManualOrder">
|
||||||
|
{{ submitting ? "创建中" : "创建补录订单" }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.picker-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-field {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-field__value {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--work-text);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-field__placeholder {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--work-text-muted);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-field__arrow {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
flex: 0 0 14rpx;
|
||||||
|
border-right: 3rpx solid var(--work-text-soft);
|
||||||
|
border-bottom: 3rpx solid var(--work-text-soft);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,15 +2,18 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||||
import { adminApi, type AdminReportDetail } from "../../api/admin";
|
import { adminApi, type AdminReportDetail } from "../../api/admin";
|
||||||
import { showErrorToast } from "../../utils/feedback";
|
import { showErrorToast, showInfoToast } from "../../utils/feedback";
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const pageReady = ref(false);
|
const pageReady = ref(false);
|
||||||
const loadError = ref("");
|
const loadError = ref("");
|
||||||
const detail = ref<AdminReportDetail | null>(null);
|
const detail = ref<AdminReportDetail | null>(null);
|
||||||
const reportId = ref(0);
|
const reportId = ref(0);
|
||||||
|
const returnInternalTagNo = ref("");
|
||||||
|
const returnConfirming = ref(false);
|
||||||
|
|
||||||
const isZhongjian = computed(() => detail.value?.report_header.service_provider === "zhongjian");
|
const isZhongjian = computed(() => detail.value?.report_header.service_provider === "zhongjian");
|
||||||
|
const isReturnReview = computed(() => Boolean(returnInternalTagNo.value && reportId.value));
|
||||||
|
|
||||||
function previewImage(urls: string[], current: string) {
|
function previewImage(urls: string[], current: string) {
|
||||||
if (!urls.length) return;
|
if (!urls.length) return;
|
||||||
@@ -79,8 +82,43 @@ async function fetchDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readQueryString(value: unknown) {
|
||||||
|
const raw = Array.isArray(value) ? value[0] : value;
|
||||||
|
const text = String(raw || "").trim();
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReturnFromReport() {
|
||||||
|
const currentDetail = detail.value;
|
||||||
|
if (!currentDetail || !returnInternalTagNo.value) {
|
||||||
|
showInfoToast("缺少回寄流转码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
returnConfirming.value = true;
|
||||||
|
try {
|
||||||
|
await adminApi.confirmWarehouseReturnReport({
|
||||||
|
internal_tag_no: returnInternalTagNo.value,
|
||||||
|
report_id: currentDetail.report_header.id || reportId.value,
|
||||||
|
});
|
||||||
|
showInfoToast("报告已确认");
|
||||||
|
uni.redirectTo({
|
||||||
|
url: `/pages/return-shipping/index?internal_tag_no=${encodeURIComponent(returnInternalTagNo.value)}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "报告确认失败");
|
||||||
|
} finally {
|
||||||
|
returnConfirming.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
reportId.value = Number(options?.id || 0);
|
reportId.value = Number(options?.id || 0);
|
||||||
|
returnInternalTagNo.value = readQueryString(options?.return_internal_tag_no);
|
||||||
if (!reportId.value) {
|
if (!reportId.value) {
|
||||||
loadError.value = "缺少报告编号,无法查看详情。";
|
loadError.value = "缺少报告编号,无法查看详情。";
|
||||||
}
|
}
|
||||||
@@ -105,6 +143,14 @@ onShow(() => {
|
|||||||
<view class="subtitle">{{ detail.report_header.report_no }}</view>
|
<view class="subtitle">{{ detail.report_header.report_no }}</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isReturnReview" class="card return-review-card">
|
||||||
|
<view class="card-title">回寄前报告核对</view>
|
||||||
|
<view class="card-desc">请核对报告编号、结论、附件和验真信息,确认无误后进入回寄信息填写。</view>
|
||||||
|
<button class="btn btn--primary main-action" :disabled="returnConfirming" @click="confirmReturnFromReport">
|
||||||
|
{{ returnConfirming ? "确认中" : "确认寄回" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="row">
|
<view class="row">
|
||||||
<view>
|
<view>
|
||||||
@@ -238,4 +284,12 @@ onShow(() => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 14rpx;
|
gap: 14rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.return-review-card {
|
||||||
|
border-color: var(--work-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-action {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
502
work-app/src/pages/return-shipping/index.vue
Normal file
502
work-app/src/pages/return-shipping/index.vue
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import { adminApi, type AdminFileAsset, type AdminWarehouseWorkbenchContext } from "../../api/admin";
|
||||||
|
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||||
|
|
||||||
|
const internalTagNo = ref("");
|
||||||
|
const expressCompany = ref("");
|
||||||
|
const trackingNo = ref("");
|
||||||
|
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const packingAttachments = ref<AdminFileAsset[]>([]);
|
||||||
|
const activeVideo = ref<AdminFileAsset | null>(null);
|
||||||
|
const RETURN_SHIPPED_STORAGE_KEY = "warehouse_return_shipped_context";
|
||||||
|
|
||||||
|
const returnConfirmed = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at));
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
returnConfirmed.value &&
|
||||||
|
Boolean(expressCompany.value.trim()) &&
|
||||||
|
Boolean(trackingNo.value.trim()) &&
|
||||||
|
!uploading.value &&
|
||||||
|
!submitting.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
function readQueryString(value: unknown) {
|
||||||
|
const raw = Array.isArray(value) ? value[0] : value;
|
||||||
|
const text = String(raw || "").trim();
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchContext() {
|
||||||
|
if (!internalTagNo.value) {
|
||||||
|
showInfoToast("缺少内部流转码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
context.value = await adminApi.lookupWarehouseReturn(internalTagNo.value);
|
||||||
|
if (!returnConfirmed.value) {
|
||||||
|
showInfoToast("请先完成报告确认");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
context.value = null;
|
||||||
|
showErrorToast(error, "回寄订单加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanTrackingNo() {
|
||||||
|
uni.scanCode({
|
||||||
|
scanType: ["barCode", "qrCode"],
|
||||||
|
success: (result) => {
|
||||||
|
trackingNo.value = String(result.result || "").trim();
|
||||||
|
},
|
||||||
|
fail: () => showInfoToast("当前环境暂不支持扫码,可手动输入"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageAsset(item: AdminFileAsset) {
|
||||||
|
return item.file_type === "image" || item.mime_type?.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoAsset(item: AdminFileAsset) {
|
||||||
|
return item.file_type === "video" || item.mime_type?.startsWith("video/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewAttachment(item: AdminFileAsset) {
|
||||||
|
if (isImageAsset(item)) {
|
||||||
|
const urls = packingAttachments.value.filter(isImageAsset).map((asset) => asset.file_url);
|
||||||
|
uni.previewImage({ urls, current: item.file_url });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVideoAsset(item)) {
|
||||||
|
activeVideo.value = item;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showInfoToast("当前附件暂不支持预览");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeVideo() {
|
||||||
|
activeVideo.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachment(fileUrl: string) {
|
||||||
|
packingAttachments.value = packingAttachments.value.filter((item) => item.file_url !== fileUrl);
|
||||||
|
showInfoToast("附件已移除");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choosePackingImage() {
|
||||||
|
try {
|
||||||
|
const result = await uni.chooseImage({
|
||||||
|
count: 9,
|
||||||
|
sizeType: ["compressed"],
|
||||||
|
sourceType: ["album", "camera"],
|
||||||
|
});
|
||||||
|
if (!result.tempFilePaths?.length) return;
|
||||||
|
uploading.value = true;
|
||||||
|
for (const filePath of result.tempFilePaths) {
|
||||||
|
const asset = await adminApi.uploadWarehouseReturnPackingFile(filePath);
|
||||||
|
packingAttachments.value.push(asset);
|
||||||
|
}
|
||||||
|
showInfoToast("图片上传成功");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "图片上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choosePackingVideo() {
|
||||||
|
try {
|
||||||
|
const result = await uni.chooseVideo({
|
||||||
|
sourceType: ["album", "camera"],
|
||||||
|
});
|
||||||
|
const filePath = result.tempFilePath;
|
||||||
|
if (!filePath) return;
|
||||||
|
uploading.value = true;
|
||||||
|
const asset = await adminApi.uploadWarehouseReturnPackingFile(filePath);
|
||||||
|
packingAttachments.value.push(asset);
|
||||||
|
showInfoToast("视频上传成功");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "视频上传失败");
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReturnShipping() {
|
||||||
|
if (!context.value) {
|
||||||
|
await fetchContext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!returnConfirmed.value) {
|
||||||
|
showInfoToast("请先完成报告确认");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!expressCompany.value.trim() || !trackingNo.value.trim()) {
|
||||||
|
showInfoToast("请填写快递公司和运单号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (uploading.value) {
|
||||||
|
showInfoToast("附件上传中,请稍后提交");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
context.value = await withLoading("正在提交", () =>
|
||||||
|
adminApi.shipWarehouseReturn({
|
||||||
|
internal_tag_no: internalTagNo.value,
|
||||||
|
express_company: expressCompany.value.trim(),
|
||||||
|
tracking_no: trackingNo.value.trim(),
|
||||||
|
packing_attachments: packingAttachments.value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
showInfoToast("寄回流程已完成");
|
||||||
|
const payload = {
|
||||||
|
internal_tag_no: internalTagNo.value,
|
||||||
|
context: context.value,
|
||||||
|
};
|
||||||
|
uni.setStorageSync(RETURN_SHIPPED_STORAGE_KEY, payload);
|
||||||
|
uni.$emit("warehouse-return-shipped", payload);
|
||||||
|
packingAttachments.value = [];
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.switchTab({ url: "/pages/scan/index" });
|
||||||
|
}, 800);
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "回寄提交失败");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
internalTagNo.value = readQueryString(options?.internal_tag_no);
|
||||||
|
void fetchContext();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="eyebrow">回寄信息</view>
|
||||||
|
<view class="title">确认回寄</view>
|
||||||
|
<view class="subtitle">填写回寄运单,并上传打包装箱图片或视频。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading && !context" class="empty">正在加载回寄订单</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view v-if="context" class="card">
|
||||||
|
<view class="row">
|
||||||
|
<view>
|
||||||
|
<view class="card-title">{{ context.product_info.product_name || "待完善物品信息" }}</view>
|
||||||
|
<view class="card-desc">{{ context.order_info.order_no }} / {{ context.order_info.appraisal_no }}</view>
|
||||||
|
</view>
|
||||||
|
<text :class="['tag', returnConfirmed ? 'tag--success' : 'tag--warning']">
|
||||||
|
{{ returnConfirmed ? "报告已确认" : "待确认报告" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">内部挂牌</view>
|
||||||
|
<view class="meta-value">{{ context.transfer_flow?.internal_tag_no || internalTagNo || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">流转阶段</view>
|
||||||
|
<view class="meta-value">{{ context.transfer_flow?.current_stage_text || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">报告编号</view>
|
||||||
|
<view class="meta-value">{{ context.report_info?.report_no || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<view class="meta-label">发布时间</view>
|
||||||
|
<view class="meta-value">{{ context.report_info?.publish_time || "-" }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="context.return_address" class="return-box">
|
||||||
|
<view class="meta-label">寄回地址</view>
|
||||||
|
<view class="meta-value">{{ context.return_address.consignee }} / {{ context.return_address.mobile }} / {{ context.return_address.full_address }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-title">快递单号</view>
|
||||||
|
<view class="card-desc">报告确认后登记回寄物流信息。</view>
|
||||||
|
<input v-model="expressCompany" class="field form-field" placeholder="回寄快递公司,例如:顺丰速运" />
|
||||||
|
<view class="scan-control">
|
||||||
|
<input v-model="trackingNo" class="field scan-input" placeholder="扫描或输入回寄运单号" />
|
||||||
|
<button class="btn scan-button" @click="scanTrackingNo">扫码</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="card-title">打包装箱附件</view>
|
||||||
|
<view class="card-desc">支持上传打包装箱图片或视频,便于留档核对。</view>
|
||||||
|
<view v-if="packingAttachments.length" class="attachment-grid">
|
||||||
|
<view v-for="item in packingAttachments" :key="item.file_url" class="attachment-tile">
|
||||||
|
<view class="attachment-preview" @click="previewAttachment(item)">
|
||||||
|
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||||
|
<video
|
||||||
|
v-else-if="isVideoAsset(item)"
|
||||||
|
class="attachment-thumb attachment-video-thumb"
|
||||||
|
:src="item.file_url"
|
||||||
|
:controls="false"
|
||||||
|
:muted="true"
|
||||||
|
:show-center-play-btn="false"
|
||||||
|
:enable-progress-gesture="false"
|
||||||
|
object-fit="cover"
|
||||||
|
/>
|
||||||
|
<view v-else class="attachment-file-thumb">文件</view>
|
||||||
|
<view v-if="isVideoAsset(item)" class="attachment-play">▶</view>
|
||||||
|
</view>
|
||||||
|
<view class="attachment-meta">
|
||||||
|
<text class="attachment-name">{{ item.name || item.file_id }}</text>
|
||||||
|
<text class="tag tag--danger attachment-remove" @click="removeAttachment(item.file_url)">移除</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="upload-actions">
|
||||||
|
<button class="upload-button" :disabled="uploading" @click="choosePackingImage">
|
||||||
|
<text class="upload-symbol">+</text>
|
||||||
|
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
||||||
|
</button>
|
||||||
|
<button class="upload-button" :disabled="uploading" @click="choosePackingVideo">
|
||||||
|
<text class="upload-symbol">+</text>
|
||||||
|
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="btn btn--primary submit-button" :disabled="!canSubmit" @click="submitReturnShipping">
|
||||||
|
{{ submitting ? "提交中" : "提交寄回" }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view v-if="activeVideo" class="video-preview-mask" @click="closeVideo">
|
||||||
|
<view class="video-preview-panel" @click.stop>
|
||||||
|
<view class="video-preview-head">
|
||||||
|
<text class="video-preview-title">{{ activeVideo.name || "装箱视频" }}</text>
|
||||||
|
<text class="video-preview-close" @click="closeVideo">关闭</text>
|
||||||
|
</view>
|
||||||
|
<video class="video-preview-player" :src="activeVideo.file_url" controls autoplay />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.form-field {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-control {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button {
|
||||||
|
width: 132rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-tile {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-video-thumb {
|
||||||
|
background: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file-thumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-play {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 54rpx;
|
||||||
|
height: 54rpx;
|
||||||
|
margin-left: -27rpx;
|
||||||
|
margin-top: -27rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(32, 33, 36, 0.72);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 54rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-remove {
|
||||||
|
min-height: 36rpx;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12rpx;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 82rpx;
|
||||||
|
padding: 0 22rpx;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button[disabled] {
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-symbol {
|
||||||
|
width: 34rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--work-accent-deep);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 32rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return-box {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--work-radius);
|
||||||
|
background: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 18rpx 22rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-close {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-player {
|
||||||
|
width: 100%;
|
||||||
|
height: 420rpx;
|
||||||
|
display: block;
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onShow } from "@dcloudio/uni-app";
|
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
import { adminApi, type AdminWarehouseWorkbenchContext } from "../../api/admin";
|
import { adminApi, type AdminFileAsset, type AdminWarehouseWorkbenchContext } from "../../api/admin";
|
||||||
import {
|
import {
|
||||||
getAdminInfo,
|
getAdminInfo,
|
||||||
resolveWorkRole,
|
resolveWorkRole,
|
||||||
@@ -11,35 +11,58 @@ import {
|
|||||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||||
|
|
||||||
type WarehouseMode = "inbound" | "outbound" | "lookup";
|
type WarehouseMode = "inbound" | "outbound" | "lookup";
|
||||||
|
type ReturnShippedPayload = {
|
||||||
|
internal_tag_no?: string;
|
||||||
|
context?: AdminWarehouseWorkbenchContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RETURN_SHIPPED_STORAGE_KEY = "warehouse_return_shipped_context";
|
||||||
|
|
||||||
const role = ref<WorkRole>(resolveWorkRole());
|
const role = ref<WorkRole>(resolveWorkRole());
|
||||||
const mode = ref<WarehouseMode>("inbound");
|
const mode = ref<WarehouseMode>("inbound");
|
||||||
const scanValue = ref("");
|
const scanValue = ref("");
|
||||||
|
const matchedInboundNo = ref("");
|
||||||
const internalTagNo = ref("");
|
const internalTagNo = ref("");
|
||||||
|
const inboundAttachments = ref<AdminFileAsset[]>([]);
|
||||||
const materialQr = ref("");
|
const materialQr = ref("");
|
||||||
const expressCompany = ref("");
|
const expressCompany = ref("");
|
||||||
const returnTrackingNo = ref("");
|
const returnTrackingNo = ref("");
|
||||||
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
|
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const actionLoading = ref(false);
|
const actionLoading = ref(false);
|
||||||
|
const uploadingInbound = ref(false);
|
||||||
|
const activeInboundVideo = ref<AdminFileAsset | null>(null);
|
||||||
|
|
||||||
const isWarehouse = computed(() => role.value === "warehouse");
|
const isWarehouse = computed(() => role.value === "warehouse");
|
||||||
const roleLabel = computed(() => roleText(role.value));
|
const roleLabel = computed(() => roleText(role.value));
|
||||||
const pageDesc = computed(() =>
|
const pageDesc = computed(() =>
|
||||||
isWarehouse.value ? "扫描快递单号或内部流转挂牌,完成入库、出库和查单。" : "扫描内部流转挂牌,进入鉴定工单处理。",
|
isWarehouse.value ? "扫描包裹或流转挂牌,完成入库、出库和查单。" : "扫描内部流转挂牌,进入鉴定工单处理。",
|
||||||
);
|
);
|
||||||
const primaryPlaceholder = computed(() => {
|
const primaryPlaceholder = computed(() => {
|
||||||
if (!isWarehouse.value) return "扫描内部流转码";
|
if (!isWarehouse.value) return "扫描内部流转码";
|
||||||
return mode.value === "inbound" ? "扫描寄入运单号" : "扫描内部流转挂牌";
|
return mode.value === "inbound" ? "扫描快递单号 / 输入鉴定订单号" : "扫描内部流转挂牌";
|
||||||
});
|
});
|
||||||
const canReceiveInbound = computed(() =>
|
const canReceiveInbound = computed(() =>
|
||||||
mode.value === "inbound" &&
|
mode.value === "inbound" &&
|
||||||
Boolean(context.value) &&
|
Boolean(context.value) &&
|
||||||
|
matchedInboundNo.value !== "" &&
|
||||||
|
matchedInboundNo.value === scanValue.value.trim() &&
|
||||||
context.value?.order_info.order_status === "pending_shipping" &&
|
context.value?.order_info.order_status === "pending_shipping" &&
|
||||||
context.value?.logistics_info?.tracking_status !== "received" &&
|
context.value?.logistics_info?.tracking_status !== "received" &&
|
||||||
context.value?.transfer_flow?.current_stage !== "warehouse_received",
|
context.value?.transfer_flow?.current_stage !== "warehouse_received",
|
||||||
);
|
);
|
||||||
const canReturnShip = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at));
|
const returnFlowEnded = computed(() =>
|
||||||
|
context.value?.transfer_flow?.flow_status === "ended" ||
|
||||||
|
context.value?.transfer_flow?.current_stage === "return_shipped" ||
|
||||||
|
Boolean(context.value?.transfer_flow?.return_shipped_at),
|
||||||
|
);
|
||||||
|
const canReturnShip = computed(() => Boolean(context.value?.transfer_flow?.return_confirmed_at) && !returnFlowEnded.value);
|
||||||
|
const outboundActionText = computed(() => {
|
||||||
|
if (actionLoading.value) return "提交中";
|
||||||
|
if (returnFlowEnded.value && !context.value?.next_action) return "寄回已完成";
|
||||||
|
if (canReturnShip.value && !context.value?.next_action) return "填写回寄信息";
|
||||||
|
return "确认操作";
|
||||||
|
});
|
||||||
|
|
||||||
function refreshRole() {
|
function refreshRole() {
|
||||||
role.value = resolveWorkRole(getAdminInfo());
|
role.value = resolveWorkRole(getAdminInfo());
|
||||||
@@ -48,13 +71,50 @@ function refreshRole() {
|
|||||||
function chooseMode(next: WarehouseMode) {
|
function chooseMode(next: WarehouseMode) {
|
||||||
mode.value = next;
|
mode.value = next;
|
||||||
scanValue.value = "";
|
scanValue.value = "";
|
||||||
|
matchedInboundNo.value = "";
|
||||||
internalTagNo.value = "";
|
internalTagNo.value = "";
|
||||||
|
inboundAttachments.value = [];
|
||||||
materialQr.value = "";
|
materialQr.value = "";
|
||||||
expressCompany.value = "";
|
expressCompany.value = "";
|
||||||
returnTrackingNo.value = "";
|
returnTrackingNo.value = "";
|
||||||
context.value = null;
|
context.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyReturnShippedPayload(payload: ReturnShippedPayload | AdminWarehouseWorkbenchContext | null | undefined) {
|
||||||
|
if (!payload) return;
|
||||||
|
const maybeContext = payload as AdminWarehouseWorkbenchContext;
|
||||||
|
const nextContext = "order_info" in maybeContext ? maybeContext : (payload as ReturnShippedPayload).context;
|
||||||
|
const nextTagNo = "order_info" in maybeContext
|
||||||
|
? maybeContext.transfer_flow?.internal_tag_no || scanValue.value.trim()
|
||||||
|
: (payload as ReturnShippedPayload).internal_tag_no || nextContext?.transfer_flow?.internal_tag_no || scanValue.value.trim();
|
||||||
|
|
||||||
|
mode.value = "outbound";
|
||||||
|
if (nextTagNo) {
|
||||||
|
scanValue.value = nextTagNo;
|
||||||
|
}
|
||||||
|
if (nextContext) {
|
||||||
|
context.value = nextContext;
|
||||||
|
}
|
||||||
|
materialQr.value = "";
|
||||||
|
expressCompany.value = "";
|
||||||
|
returnTrackingNo.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReturnShipped(payload: ReturnShippedPayload | AdminWarehouseWorkbenchContext) {
|
||||||
|
applyReturnShippedPayload(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncReturnShippedStateFromStorage() {
|
||||||
|
try {
|
||||||
|
const payload = uni.getStorageSync(RETURN_SHIPPED_STORAGE_KEY) as ReturnShippedPayload | AdminWarehouseWorkbenchContext | "";
|
||||||
|
if (!payload) return;
|
||||||
|
uni.removeStorageSync(RETURN_SHIPPED_STORAGE_KEY);
|
||||||
|
applyReturnShippedPayload(payload);
|
||||||
|
} catch {
|
||||||
|
uni.removeStorageSync(RETURN_SHIPPED_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyScanResult(value: string) {
|
function applyScanResult(value: string) {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
scanValue.value = value.trim();
|
scanValue.value = value.trim();
|
||||||
@@ -69,8 +129,20 @@ function openScanner() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateScanValue(event: unknown) {
|
||||||
|
const inputEvent = event as { detail?: { value?: unknown }; target?: { value?: unknown } };
|
||||||
|
const value = inputEvent.detail?.value ?? inputEvent.target?.value;
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
scanValue.value = String(value).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentScanValue() {
|
||||||
|
return scanValue.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePrimaryAction() {
|
async function handlePrimaryAction() {
|
||||||
if (!scanValue.value.trim()) {
|
if (!currentScanValue()) {
|
||||||
showInfoToast(primaryPlaceholder.value);
|
showInfoToast(primaryPlaceholder.value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -90,33 +162,62 @@ async function handlePrimaryAction() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function lookupInbound() {
|
async function lookupInbound() {
|
||||||
|
const inboundNo = currentScanValue();
|
||||||
|
if (!inboundNo) {
|
||||||
|
showInfoToast("请扫描快递单号或输入鉴定订单号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
context.value = await adminApi.lookupWarehouseInbound(scanValue.value.trim());
|
if (matchedInboundNo.value !== inboundNo) {
|
||||||
|
internalTagNo.value = "";
|
||||||
|
inboundAttachments.value = [];
|
||||||
|
}
|
||||||
|
context.value = await adminApi.lookupWarehouseInbound(inboundNo);
|
||||||
|
matchedInboundNo.value = inboundNo;
|
||||||
showInfoToast("已匹配订单");
|
showInfoToast("已匹配订单");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
context.value = null;
|
context.value = null;
|
||||||
showErrorToast(error, "入库查询失败");
|
showErrorToast(error, "未匹配到待入库订单");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function receiveInbound() {
|
async function receiveInbound() {
|
||||||
if (!scanValue.value.trim() || !internalTagNo.value.trim()) {
|
const inboundNo = currentScanValue();
|
||||||
showInfoToast("请填写寄入运单号和内部流转挂牌");
|
if (!inboundNo) {
|
||||||
|
showInfoToast("请先扫描快递单号或输入鉴定订单号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!context.value) {
|
||||||
|
showInfoToast("请先匹配订单信息");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchedInboundNo.value !== inboundNo) {
|
||||||
|
showInfoToast("订单信息已变化,请重新匹配");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!internalTagNo.value.trim()) {
|
||||||
|
showInfoToast("请扫描流转码挂牌");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (uploadingInbound.value) {
|
||||||
|
showInfoToast("附件上传中,请稍后提交");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
actionLoading.value = true;
|
actionLoading.value = true;
|
||||||
try {
|
try {
|
||||||
context.value = await withLoading("正在入库", () =>
|
context.value = await withLoading("正在入库", () =>
|
||||||
adminApi.receiveWarehouseInbound({
|
adminApi.receiveWarehouseInbound({
|
||||||
tracking_no: scanValue.value.trim(),
|
inbound_no: inboundNo,
|
||||||
internal_tag_no: internalTagNo.value.trim(),
|
internal_tag_no: internalTagNo.value.trim(),
|
||||||
|
inbound_attachments: inboundAttachments.value,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
showInfoToast("入库完成");
|
showInfoToast("入库完成");
|
||||||
internalTagNo.value = "";
|
internalTagNo.value = "";
|
||||||
|
inboundAttachments.value = [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast(error, "入库失败");
|
showErrorToast(error, "入库失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -124,6 +225,77 @@ async function receiveInbound() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function chooseInboundImage() {
|
||||||
|
try {
|
||||||
|
const result = await uni.chooseImage({
|
||||||
|
count: 9,
|
||||||
|
sizeType: ["compressed"],
|
||||||
|
sourceType: ["album", "camera"],
|
||||||
|
});
|
||||||
|
if (!result.tempFilePaths?.length) return;
|
||||||
|
uploadingInbound.value = true;
|
||||||
|
for (const filePath of result.tempFilePaths) {
|
||||||
|
const asset = await adminApi.uploadWarehouseInboundEvidenceFile(filePath);
|
||||||
|
inboundAttachments.value.push(asset);
|
||||||
|
}
|
||||||
|
showInfoToast("图片上传成功");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "图片上传失败");
|
||||||
|
} finally {
|
||||||
|
uploadingInbound.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseInboundVideo() {
|
||||||
|
try {
|
||||||
|
const result = await uni.chooseVideo({
|
||||||
|
sourceType: ["album", "camera"],
|
||||||
|
});
|
||||||
|
const filePath = result.tempFilePath;
|
||||||
|
if (!filePath) return;
|
||||||
|
uploadingInbound.value = true;
|
||||||
|
const asset = await adminApi.uploadWarehouseInboundEvidenceFile(filePath);
|
||||||
|
inboundAttachments.value.push(asset);
|
||||||
|
showInfoToast("视频上传成功");
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "视频上传失败");
|
||||||
|
} finally {
|
||||||
|
uploadingInbound.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeInboundAttachment(fileUrl: string) {
|
||||||
|
inboundAttachments.value = inboundAttachments.value.filter((item) => item.file_url !== fileUrl);
|
||||||
|
showInfoToast("附件已移除");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageAsset(item: AdminFileAsset) {
|
||||||
|
return item.file_type === "image" || item.mime_type?.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoAsset(item: AdminFileAsset) {
|
||||||
|
return item.file_type === "video" || item.mime_type?.startsWith("video/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewInboundAttachment(item: AdminFileAsset) {
|
||||||
|
if (isImageAsset(item)) {
|
||||||
|
const urls = inboundAttachments.value.filter(isImageAsset).map((asset) => asset.file_url);
|
||||||
|
uni.previewImage({ urls, current: item.file_url });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVideoAsset(item)) {
|
||||||
|
activeInboundVideo.value = item;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showInfoToast("当前附件暂不支持预览");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInboundVideo() {
|
||||||
|
activeInboundVideo.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function lookupOutbound() {
|
async function lookupOutbound() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -143,6 +315,19 @@ async function lookupOutbound() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openReturnReportReview() {
|
||||||
|
const reportId = Number(context.value?.report_info?.id || context.value?.return_verification?.report_id || 0);
|
||||||
|
const tagNo = scanValue.value.trim();
|
||||||
|
if (!reportId || !tagNo) {
|
||||||
|
showInfoToast("未找到可核对的报告");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/report/detail?id=${reportId}&return_internal_tag_no=${encodeURIComponent(tagNo)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function submitOutboundAction() {
|
async function submitOutboundAction() {
|
||||||
if (!context.value) {
|
if (!context.value) {
|
||||||
await lookupOutbound();
|
await lookupOutbound();
|
||||||
@@ -160,9 +345,12 @@ async function submitOutboundAction() {
|
|||||||
showInfoToast("送检入库完成");
|
showInfoToast("送检入库完成");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (returnFlowEnded.value) {
|
||||||
|
showInfoToast("寄回流程已完成");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (context.value.order_info.service_provider === "zhongjian") {
|
if (context.value.order_info.service_provider === "zhongjian") {
|
||||||
context.value = await adminApi.confirmWarehouseReturnZhongjian(scanValue.value.trim());
|
openReturnReportReview();
|
||||||
showInfoToast("中检报告已确认");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!canReturnShip.value) {
|
if (!canReturnShip.value) {
|
||||||
@@ -174,19 +362,11 @@ async function submitOutboundAction() {
|
|||||||
internal_tag_no: scanValue.value.trim(),
|
internal_tag_no: scanValue.value.trim(),
|
||||||
qr_input: materialQr.value.trim(),
|
qr_input: materialQr.value.trim(),
|
||||||
});
|
});
|
||||||
showInfoToast("验真吊牌已确认");
|
showInfoToast("验真吊牌匹配通过,请核对报告");
|
||||||
|
openReturnReportReview();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!expressCompany.value.trim() || !returnTrackingNo.value.trim()) {
|
uni.navigateTo({ url: `/pages/return-shipping/index?internal_tag_no=${encodeURIComponent(scanValue.value.trim())}` });
|
||||||
showInfoToast("请填写回寄快递和运单号");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
context.value = await adminApi.shipWarehouseReturn({
|
|
||||||
internal_tag_no: scanValue.value.trim(),
|
|
||||||
express_company: expressCompany.value.trim(),
|
|
||||||
tracking_no: returnTrackingNo.value.trim(),
|
|
||||||
});
|
|
||||||
showInfoToast("回寄运单已登记");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast(error, "出库操作失败");
|
showErrorToast(error, "出库操作失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -198,10 +378,10 @@ async function lookupAnyOrder() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
context.value = await adminApi.lookupWarehouseInbound(scanValue.value.trim());
|
context.value = await adminApi.lookupWarehouseInbound(currentScanValue());
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
context.value = await adminApi.lookupWarehouseReturn(scanValue.value.trim());
|
context.value = await adminApi.lookupWarehouseReturn(currentScanValue());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
context.value = null;
|
context.value = null;
|
||||||
@@ -244,7 +424,18 @@ function scanMaterialQr() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onShow(refreshRole);
|
onLoad(() => {
|
||||||
|
uni.$on("warehouse-return-shipped", handleReturnShipped);
|
||||||
|
});
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
refreshRole();
|
||||||
|
syncReturnShippedStateFromStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnload(() => {
|
||||||
|
uni.$off("warehouse-return-shipped", handleReturnShipped);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -266,22 +457,56 @@ onShow(refreshRole);
|
|||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="card-title">{{ primaryPlaceholder }}</view>
|
<view class="card-title">{{ primaryPlaceholder }}</view>
|
||||||
<view class="scan-control">
|
<view class="scan-control">
|
||||||
<input v-model="scanValue" class="field scan-input" :placeholder="primaryPlaceholder" @confirm="handlePrimaryAction" />
|
<input :value="scanValue" class="field scan-input" :placeholder="primaryPlaceholder" @input="updateScanValue" @confirm="handlePrimaryAction" />
|
||||||
<button class="btn scan-button" @click="openScanner">扫码</button>
|
<button class="btn scan-button" @click="openScanner">扫码</button>
|
||||||
</view>
|
</view>
|
||||||
<button class="btn btn--primary main-action" :disabled="loading" @click="handlePrimaryAction">
|
<button class="btn btn--primary main-action" :disabled="loading" @click="handlePrimaryAction">
|
||||||
{{ loading ? "处理中" : isWarehouse ? "识别" : "打开工单" }}
|
{{ loading ? "处理中" : mode === 'inbound' && isWarehouse ? "匹配订单" : isWarehouse ? "识别" : "打开工单" }}
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="canReceiveInbound" class="card">
|
<view v-if="canReceiveInbound" class="card">
|
||||||
<view class="card-title">入库绑定</view>
|
<view class="card-title">扫描流转码挂牌绑定</view>
|
||||||
<view class="scan-control">
|
<view class="scan-control">
|
||||||
<input v-model="internalTagNo" class="field scan-input" placeholder="内部流转挂牌" />
|
<input v-model="internalTagNo" class="field scan-input" placeholder="扫描或输入流转码挂牌" />
|
||||||
<button class="btn scan-button" @click="scanInternalTagInput">扫码</button>
|
<button class="btn scan-button" @click="scanInternalTagInput">扫码</button>
|
||||||
</view>
|
</view>
|
||||||
<button class="btn btn--primary main-action" :disabled="actionLoading" @click="receiveInbound">
|
<view class="card-desc">拆包视频或图片附件可选上传。</view>
|
||||||
{{ actionLoading ? "入库中" : "确认入库" }}
|
<view v-if="inboundAttachments.length" class="attachment-grid">
|
||||||
|
<view v-for="item in inboundAttachments" :key="item.file_url" class="attachment-tile">
|
||||||
|
<view class="attachment-preview" @click="previewInboundAttachment(item)">
|
||||||
|
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||||
|
<video
|
||||||
|
v-else-if="isVideoAsset(item)"
|
||||||
|
class="attachment-thumb attachment-video-thumb"
|
||||||
|
:src="item.file_url"
|
||||||
|
:controls="false"
|
||||||
|
:muted="true"
|
||||||
|
:show-center-play-btn="false"
|
||||||
|
:enable-progress-gesture="false"
|
||||||
|
object-fit="cover"
|
||||||
|
/>
|
||||||
|
<view v-else class="attachment-file-thumb">文件</view>
|
||||||
|
<view v-if="isVideoAsset(item)" class="attachment-play">▶</view>
|
||||||
|
</view>
|
||||||
|
<view class="attachment-meta">
|
||||||
|
<text class="attachment-name">{{ item.name || item.file_id }}</text>
|
||||||
|
<text class="tag tag--danger attachment-remove" @click="removeInboundAttachment(item.file_url)">移除</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="upload-actions">
|
||||||
|
<button class="upload-button" :disabled="uploadingInbound" @click="chooseInboundImage">
|
||||||
|
<text class="upload-symbol">+</text>
|
||||||
|
<text>{{ uploadingInbound ? "上传中" : "添加图片" }}</text>
|
||||||
|
</button>
|
||||||
|
<button class="upload-button" :disabled="uploadingInbound" @click="chooseInboundVideo">
|
||||||
|
<text class="upload-symbol">+</text>
|
||||||
|
<text>{{ uploadingInbound ? "上传中" : "添加视频" }}</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<button class="btn btn--primary main-action" :disabled="actionLoading || uploadingInbound || !canReceiveInbound" @click="receiveInbound">
|
||||||
|
{{ actionLoading ? "入库中" : "提交入库完成" }}
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -290,16 +515,18 @@ onShow(refreshRole);
|
|||||||
<view class="card-desc">
|
<view class="card-desc">
|
||||||
{{ context.next_action_text || (context.order_info.service_provider === 'zhongjian' ? '确认中检报告后回寄' : '确认验真吊牌后回寄') }}
|
{{ context.next_action_text || (context.order_info.service_provider === 'zhongjian' ? '确认中检报告后回寄' : '确认验真吊牌后回寄') }}
|
||||||
</view>
|
</view>
|
||||||
<view v-if="context.order_info.service_provider !== 'zhongjian' && !canReturnShip && !context.next_action" class="scan-control">
|
<view v-if="context.order_info.service_provider !== 'zhongjian' && !canReturnShip && !returnFlowEnded && !context.next_action" class="scan-control">
|
||||||
<input v-model="materialQr" class="field scan-input" placeholder="验真吊牌二维码" />
|
<input v-model="materialQr" class="field scan-input" placeholder="验真吊牌二维码" />
|
||||||
<button class="btn scan-button" @click="scanMaterialQr">扫码</button>
|
<button class="btn scan-button" @click="scanMaterialQr">扫码</button>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="canReturnShip && !context.next_action" class="ship-fields">
|
<view v-if="canReturnShip && !context.next_action" class="ship-fields">
|
||||||
<input v-model="expressCompany" class="field" placeholder="回寄快递公司" />
|
<view class="card-desc">报告已确认,可进入回寄信息页填写快递单号并上传打包装箱附件。</view>
|
||||||
<input v-model="returnTrackingNo" class="field" placeholder="回寄运单号" />
|
|
||||||
</view>
|
</view>
|
||||||
<button class="btn btn--primary main-action" :disabled="actionLoading" @click="submitOutboundAction">
|
<view v-if="returnFlowEnded && !context.next_action" class="ship-fields">
|
||||||
{{ actionLoading ? "提交中" : "确认操作" }}
|
<view class="card-desc">寄回流程已完成,无需重复填写回寄信息。</view>
|
||||||
|
</view>
|
||||||
|
<button class="btn btn--primary main-action" :disabled="actionLoading || (returnFlowEnded && !context.next_action)" @click="submitOutboundAction">
|
||||||
|
{{ outboundActionText }}
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -334,6 +561,16 @@ onShow(refreshRole);
|
|||||||
<view class="meta-value">{{ context.return_address.consignee }} / {{ context.return_address.mobile }} / {{ context.return_address.full_address }}</view>
|
<view class="meta-value">{{ context.return_address.consignee }} / {{ context.return_address.mobile }} / {{ context.return_address.full_address }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="activeInboundVideo" class="video-preview-mask" @click="closeInboundVideo">
|
||||||
|
<view class="video-preview-panel" @click.stop>
|
||||||
|
<view class="video-preview-head">
|
||||||
|
<text class="video-preview-title">{{ activeInboundVideo.name || "拆包视频" }}</text>
|
||||||
|
<text class="video-preview-close" @click="closeInboundVideo">关闭</text>
|
||||||
|
</view>
|
||||||
|
<video class="video-preview-player" :src="activeInboundVideo.file_url" controls autoplay />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -357,6 +594,128 @@ onShow(refreshRole);
|
|||||||
margin-top: 18rpx;
|
margin-top: 18rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachment-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-tile {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-video-thumb {
|
||||||
|
background: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file-thumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-play {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 54rpx;
|
||||||
|
height: 54rpx;
|
||||||
|
margin-left: -27rpx;
|
||||||
|
margin-top: -27rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(32, 33, 36, 0.72);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 54rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-remove {
|
||||||
|
min-height: 36rpx;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12rpx;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 82rpx;
|
||||||
|
padding: 0 22rpx;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button[disabled] {
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-symbol {
|
||||||
|
width: 34rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--work-accent-deep);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 32rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.ship-fields {
|
.ship-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14rpx;
|
gap: 14rpx;
|
||||||
@@ -369,4 +728,54 @@ onShow(refreshRole);
|
|||||||
border-radius: var(--work-radius-sm);
|
border-radius: var(--work-radius-sm);
|
||||||
background: var(--work-warning-soft);
|
background: var(--work-warning-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-preview-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--work-radius);
|
||||||
|
background: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 18rpx 22rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-close {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-player {
|
||||||
|
width: 100%;
|
||||||
|
height: 420rpx;
|
||||||
|
display: block;
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const internalRemark = ref("");
|
|||||||
const zhongjianReportNo = ref("");
|
const zhongjianReportNo = ref("");
|
||||||
const zhongjianFiles = ref<AdminFileAsset[]>([]);
|
const zhongjianFiles = ref<AdminFileAsset[]>([]);
|
||||||
const evidenceFiles = ref<AdminFileAsset[]>([]);
|
const evidenceFiles = ref<AdminFileAsset[]>([]);
|
||||||
|
const activePreviewVideo = ref<AdminFileAsset | null>(null);
|
||||||
const supplementForm = reactive({
|
const supplementForm = reactive({
|
||||||
reason: "",
|
reason: "",
|
||||||
deadline: "",
|
deadline: "",
|
||||||
@@ -33,6 +34,11 @@ const supplementForm = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isZhongjian = computed(() => detail.value?.task_info.service_provider === "zhongjian");
|
const isZhongjian = computed(() => detail.value?.task_info.service_provider === "zhongjian");
|
||||||
|
const isTaskReadonly = computed(() => {
|
||||||
|
const status = detail.value?.task_info.status || "";
|
||||||
|
return status === "submitted" || status === "completed";
|
||||||
|
});
|
||||||
|
const internalTagNo = computed(() => detail.value?.task_info.internal_tag_no || "");
|
||||||
const resultSummary = computed(() => detail.value?.result_info.result_text || "暂未填写");
|
const resultSummary = computed(() => detail.value?.result_info.result_text || "暂未填写");
|
||||||
const reportSummary = computed(() => detail.value?.report_summary?.report_no || "");
|
const reportSummary = computed(() => detail.value?.report_summary?.report_no || "");
|
||||||
type AppraisalTemplate = NonNullable<AdminAppraisalTaskDetail["appraisal_template"]>;
|
type AppraisalTemplate = NonNullable<AdminAppraisalTaskDetail["appraisal_template"]>;
|
||||||
@@ -129,10 +135,18 @@ async function fetchDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addSupplementItem() {
|
function addSupplementItem() {
|
||||||
|
if (isTaskReadonly.value) {
|
||||||
|
showInfoToast("当前任务已完成,不能再编辑");
|
||||||
|
return;
|
||||||
|
}
|
||||||
supplementForm.items.push({ item_name: "", guide_text: "", is_required: true });
|
supplementForm.items.push({ item_name: "", guide_text: "", is_required: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSupplementItem(index: number) {
|
function removeSupplementItem(index: number) {
|
||||||
|
if (isTaskReadonly.value) {
|
||||||
|
showInfoToast("当前任务已完成,不能再编辑");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (supplementForm.items.length === 1) {
|
if (supplementForm.items.length === 1) {
|
||||||
supplementForm.items[0].item_name = "";
|
supplementForm.items[0].item_name = "";
|
||||||
supplementForm.items[0].guide_text = "";
|
supplementForm.items[0].guide_text = "";
|
||||||
@@ -143,8 +157,12 @@ function removeSupplementItem(index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeEvidenceFile(fileUrl: string) {
|
async function removeEvidenceFile(fileUrl: string) {
|
||||||
|
if (isTaskReadonly.value || !detail.value) {
|
||||||
|
showInfoToast("当前任务已完成,不能再删除附件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
|
await adminApi.deleteAppraisalEvidenceFile(fileUrl, detail.value.task_info.id);
|
||||||
evidenceFiles.value = evidenceFiles.value.filter((item) => item.file_url !== fileUrl);
|
evidenceFiles.value = evidenceFiles.value.filter((item) => item.file_url !== fileUrl);
|
||||||
showInfoToast("附件已删除");
|
showInfoToast("附件已删除");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -153,8 +171,12 @@ async function removeEvidenceFile(fileUrl: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeZhongjianFile(fileUrl: string) {
|
async function removeZhongjianFile(fileUrl: string) {
|
||||||
|
if (isTaskReadonly.value || !detail.value) {
|
||||||
|
showInfoToast("当前任务已完成,不能再删除文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await adminApi.deleteAppraisalEvidenceFile(fileUrl);
|
await adminApi.deleteAppraisalEvidenceFile(fileUrl, detail.value.task_info.id);
|
||||||
zhongjianFiles.value = zhongjianFiles.value.filter((item) => item.file_url !== fileUrl);
|
zhongjianFiles.value = zhongjianFiles.value.filter((item) => item.file_url !== fileUrl);
|
||||||
showInfoToast("文件已删除");
|
showInfoToast("文件已删除");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -162,7 +184,41 @@ async function removeZhongjianFile(fileUrl: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isImageAsset(item: AdminFileAsset) {
|
||||||
|
return item.file_type === "image" || item.mime_type?.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoAsset(item: AdminFileAsset) {
|
||||||
|
return item.file_type === "video" || item.mime_type?.startsWith("video/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentTypeLabel(item: AdminFileAsset) {
|
||||||
|
if (isImageAsset(item)) return "图片";
|
||||||
|
if (isVideoAsset(item)) return "视频";
|
||||||
|
return "附件";
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewAttachment(files: AdminFileAsset[], item: AdminFileAsset) {
|
||||||
|
if (isImageAsset(item)) {
|
||||||
|
const urls = files.filter(isImageAsset).map((asset) => asset.file_url);
|
||||||
|
uni.previewImage({ urls, current: item.file_url });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVideoAsset(item)) {
|
||||||
|
activePreviewVideo.value = item;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showInfoToast("当前附件暂不支持预览");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePreviewVideo() {
|
||||||
|
activePreviewVideo.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
function updateTemplatePoint(index: number, key: "point_value" | "point_remark", value: string) {
|
function updateTemplatePoint(index: number, key: "point_value" | "point_remark", value: string) {
|
||||||
|
if (isTaskReadonly.value) return;
|
||||||
const template = detail.value?.appraisal_template;
|
const template = detail.value?.appraisal_template;
|
||||||
if (!template) return;
|
if (!template) return;
|
||||||
const current = template.key_points[index];
|
const current = template.key_points[index];
|
||||||
@@ -195,7 +251,90 @@ function returnToWorkOrders(message: string) {
|
|||||||
}, 700);
|
}, 700);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmPublishReport() {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: "提交确认",
|
||||||
|
content: "是否已鉴定完成并确定发布报告?",
|
||||||
|
cancelText: "取消",
|
||||||
|
confirmText: "去绑定",
|
||||||
|
success: (result) => resolve(Boolean(result.confirm)),
|
||||||
|
fail: () => resolve(false),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptMaterialTagQrInput() {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: "绑定验真吊牌",
|
||||||
|
content: "本地预览无法直接扫码,请输入或粘贴吊牌二维码内容。",
|
||||||
|
editable: true,
|
||||||
|
placeholderText: "二维码内容 / 验真吊牌编号",
|
||||||
|
cancelText: "取消",
|
||||||
|
confirmText: "绑定",
|
||||||
|
success: (result) => {
|
||||||
|
if (!result.confirm) {
|
||||||
|
reject(new Error("已取消绑定验真吊牌"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qrInput = String(result.content || "").trim();
|
||||||
|
if (!qrInput) {
|
||||||
|
reject(new Error("请输入验真吊牌二维码内容"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(qrInput);
|
||||||
|
},
|
||||||
|
fail: () => reject(new Error("已取消绑定验真吊牌")),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanMaterialTagQr() {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
uni.scanCode({
|
||||||
|
scanType: ["barCode", "qrCode"],
|
||||||
|
success: (result) => {
|
||||||
|
const qrInput = String(result.result || "").trim();
|
||||||
|
if (!qrInput) {
|
||||||
|
reject(new Error("未识别到验真吊牌二维码"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(qrInput);
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
// #ifdef H5
|
||||||
|
promptMaterialTagQrInput().then(resolve).catch(reject);
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
reject(new Error("已取消绑定验真吊牌"));
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAndScanMaterialTag() {
|
||||||
|
const confirmed = await confirmPublishReport();
|
||||||
|
if (!confirmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await scanMaterialTagQr();
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, "验真吊牌扫码失败");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function chooseEvidenceImage() {
|
async function chooseEvidenceImage() {
|
||||||
|
if (isTaskReadonly.value || !detail.value) {
|
||||||
|
showInfoToast("当前任务已完成,不能再上传附件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await uni.chooseImage({
|
const result = await uni.chooseImage({
|
||||||
count: 9,
|
count: 9,
|
||||||
@@ -205,7 +344,7 @@ async function chooseEvidenceImage() {
|
|||||||
if (!result.tempFilePaths?.length) return;
|
if (!result.tempFilePaths?.length) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
for (const filePath of result.tempFilePaths) {
|
for (const filePath of result.tempFilePaths) {
|
||||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
|
||||||
evidenceFiles.value.push(asset);
|
evidenceFiles.value.push(asset);
|
||||||
}
|
}
|
||||||
showInfoToast("图片上传成功");
|
showInfoToast("图片上传成功");
|
||||||
@@ -217,6 +356,10 @@ async function chooseEvidenceImage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function chooseEvidenceVideo() {
|
async function chooseEvidenceVideo() {
|
||||||
|
if (isTaskReadonly.value || !detail.value) {
|
||||||
|
showInfoToast("当前任务已完成,不能再上传附件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await uni.chooseVideo({
|
const result = await uni.chooseVideo({
|
||||||
sourceType: ["album", "camera"],
|
sourceType: ["album", "camera"],
|
||||||
@@ -224,7 +367,7 @@ async function chooseEvidenceVideo() {
|
|||||||
const filePath = result.tempFilePath;
|
const filePath = result.tempFilePath;
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
|
||||||
evidenceFiles.value.push(asset);
|
evidenceFiles.value.push(asset);
|
||||||
showInfoToast("视频上传成功");
|
showInfoToast("视频上传成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -235,6 +378,10 @@ async function chooseEvidenceVideo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function chooseZhongjianImage() {
|
async function chooseZhongjianImage() {
|
||||||
|
if (isTaskReadonly.value || !detail.value) {
|
||||||
|
showInfoToast("当前任务已完成,不能再上传文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await uni.chooseImage({
|
const result = await uni.chooseImage({
|
||||||
count: 9,
|
count: 9,
|
||||||
@@ -244,7 +391,7 @@ async function chooseZhongjianImage() {
|
|||||||
if (!result.tempFilePaths?.length) return;
|
if (!result.tempFilePaths?.length) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
for (const filePath of result.tempFilePaths) {
|
for (const filePath of result.tempFilePaths) {
|
||||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
|
||||||
zhongjianFiles.value.push(asset);
|
zhongjianFiles.value.push(asset);
|
||||||
}
|
}
|
||||||
showInfoToast("图片上传成功");
|
showInfoToast("图片上传成功");
|
||||||
@@ -256,6 +403,10 @@ async function chooseZhongjianImage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function chooseZhongjianVideo() {
|
async function chooseZhongjianVideo() {
|
||||||
|
if (isTaskReadonly.value || !detail.value) {
|
||||||
|
showInfoToast("当前任务已完成,不能再上传文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await uni.chooseVideo({
|
const result = await uni.chooseVideo({
|
||||||
sourceType: ["album", "camera"],
|
sourceType: ["album", "camera"],
|
||||||
@@ -263,7 +414,7 @@ async function chooseZhongjianVideo() {
|
|||||||
const filePath = result.tempFilePath;
|
const filePath = result.tempFilePath;
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath);
|
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
|
||||||
zhongjianFiles.value.push(asset);
|
zhongjianFiles.value.push(asset);
|
||||||
showInfoToast("视频上传成功");
|
showInfoToast("视频上传成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -275,6 +426,10 @@ async function chooseZhongjianVideo() {
|
|||||||
|
|
||||||
async function submitResult(action: "save" | "submit") {
|
async function submitResult(action: "save" | "submit") {
|
||||||
if (!detail.value) return;
|
if (!detail.value) return;
|
||||||
|
if (isTaskReadonly.value) {
|
||||||
|
showInfoToast("当前任务已完成,不能再提交");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isZhongjian.value) {
|
if (isZhongjian.value) {
|
||||||
showInfoToast("中检订单请切换到中检报告区");
|
showInfoToast("中检订单请切换到中检报告区");
|
||||||
activeSection.value = "zhongjian";
|
activeSection.value = "zhongjian";
|
||||||
@@ -286,6 +441,11 @@ async function submitResult(action: "save" | "submit") {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const qrInput = action === "submit" ? await confirmAndScanMaterialTag() : "";
|
||||||
|
if (action === "submit" && !qrInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
const conditionPayload = showConditionFields.value
|
const conditionPayload = showConditionFields.value
|
||||||
@@ -330,10 +490,11 @@ async function submitResult(action: "save" | "submit") {
|
|||||||
internal_remark: internalRemark.value.trim(),
|
internal_remark: internalRemark.value.trim(),
|
||||||
attachments: evidenceFiles.value,
|
attachments: evidenceFiles.value,
|
||||||
key_points: templateKeyPointsPayload(),
|
key_points: templateKeyPointsPayload(),
|
||||||
|
...(qrInput ? { qr_input: qrInput } : {}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (action === "submit") {
|
if (action === "submit") {
|
||||||
returnToWorkOrders("鉴定已提交,正在返回工单");
|
returnToWorkOrders("验真吊牌已绑定,报告已发布");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showInfoToast("鉴定已保存");
|
showInfoToast("鉴定已保存");
|
||||||
@@ -347,6 +508,10 @@ async function submitResult(action: "save" | "submit") {
|
|||||||
|
|
||||||
async function submitSupplement() {
|
async function submitSupplement() {
|
||||||
if (!detail.value) return;
|
if (!detail.value) return;
|
||||||
|
if (isTaskReadonly.value) {
|
||||||
|
showInfoToast("当前任务已完成,不能再发起补资料");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const items = supplementForm.items.filter((item) => item.item_name.trim());
|
const items = supplementForm.items.filter((item) => item.item_name.trim());
|
||||||
if (!supplementForm.reason.trim()) {
|
if (!supplementForm.reason.trim()) {
|
||||||
showInfoToast("请先填写补资料原因");
|
showInfoToast("请先填写补资料原因");
|
||||||
@@ -380,6 +545,10 @@ async function submitSupplement() {
|
|||||||
|
|
||||||
async function submitZhongjianReport() {
|
async function submitZhongjianReport() {
|
||||||
if (!detail.value) return;
|
if (!detail.value) return;
|
||||||
|
if (isTaskReadonly.value) {
|
||||||
|
showInfoToast("当前任务已完成,不能再提交");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!zhongjianReportNo.value.trim()) {
|
if (!zhongjianReportNo.value.trim()) {
|
||||||
showInfoToast("请填写中检报告编号");
|
showInfoToast("请填写中检报告编号");
|
||||||
return;
|
return;
|
||||||
@@ -389,14 +558,20 @@ async function submitZhongjianReport() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const qrInput = await confirmAndScanMaterialTag();
|
||||||
|
if (!qrInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await adminApi.saveZhongjianAppraisalReport({
|
await adminApi.saveZhongjianAppraisalReport({
|
||||||
id: detail.value.task_info.id,
|
id: detail.value.task_info.id,
|
||||||
zhongjian_report_no: zhongjianReportNo.value.trim(),
|
zhongjian_report_no: zhongjianReportNo.value.trim(),
|
||||||
report_files: zhongjianFiles.value,
|
report_files: zhongjianFiles.value,
|
||||||
|
qr_input: qrInput,
|
||||||
});
|
});
|
||||||
returnToWorkOrders("中检报告已提交,正在返回工单");
|
returnToWorkOrders("验真吊牌已绑定,报告已发布");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast(error, "中检报告录入失败");
|
showErrorToast(error, "中检报告录入失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -418,7 +593,7 @@ onLoad((options) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
if (taskId.value) {
|
if (taskId.value && !pageReady.value) {
|
||||||
void fetchDetail();
|
void fetchDetail();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -461,8 +636,16 @@ onShow(() => {
|
|||||||
<view class="meta-label">报告摘要</view>
|
<view class="meta-label">报告摘要</view>
|
||||||
<view class="meta-value">{{ reportSummary || "-" }}</view>
|
<view class="meta-value">{{ reportSummary || "-" }}</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="internalTagNo" class="meta-item meta-item--wide">
|
||||||
|
<view class="meta-label">流转码编号</view>
|
||||||
|
<view class="meta-value transfer-code-value">{{ internalTagNo }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isTaskReadonly" class="readonly-notice">
|
||||||
|
当前工单已完成,鉴定内容和附件仅可查看。
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="segmented">
|
<view class="segmented">
|
||||||
@@ -475,21 +658,21 @@ onShow(() => {
|
|||||||
<view v-if="activeSection === 'result' && !isZhongjian" class="card">
|
<view v-if="activeSection === 'result' && !isZhongjian" class="card">
|
||||||
<view class="card-title">鉴定结论</view>
|
<view class="card-title">鉴定结论</view>
|
||||||
<view class="stack" style="margin-top: 18rpx">
|
<view class="stack" style="margin-top: 18rpx">
|
||||||
<input v-model="resultText" class="field" placeholder="结论,例如:正品 / 存疑" />
|
<input v-model="resultText" class="field" :disabled="isTaskReadonly" placeholder="结论,例如:正品 / 存疑" />
|
||||||
<textarea v-model="resultDesc" class="textarea" placeholder="结论说明" />
|
<textarea v-model="resultDesc" class="textarea" :disabled="isTaskReadonly" placeholder="结论说明" />
|
||||||
<template v-if="showConditionFields">
|
<template v-if="showConditionFields">
|
||||||
<input v-model="conditionGrade" class="field" placeholder="成色评级" />
|
<input v-model="conditionGrade" class="field" :disabled="isTaskReadonly" placeholder="成色评级" />
|
||||||
<textarea v-model="conditionDesc" class="textarea" placeholder="成色说明" />
|
<textarea v-model="conditionDesc" class="textarea" :disabled="isTaskReadonly" placeholder="成色说明" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="showValuationFields">
|
<template v-if="showValuationFields">
|
||||||
<view class="meta-grid">
|
<view class="meta-grid">
|
||||||
<input v-model="valuationMin" class="field" placeholder="最低估值" />
|
<input v-model="valuationMin" class="field" :disabled="isTaskReadonly" placeholder="最低估值" />
|
||||||
<input v-model="valuationMax" class="field" placeholder="最高估值" />
|
<input v-model="valuationMax" class="field" :disabled="isTaskReadonly" placeholder="最高估值" />
|
||||||
</view>
|
</view>
|
||||||
<textarea v-model="valuationDesc" class="textarea" placeholder="估值说明" />
|
<textarea v-model="valuationDesc" class="textarea" :disabled="isTaskReadonly" placeholder="估值说明" />
|
||||||
</template>
|
</template>
|
||||||
<textarea v-model="externalRemark" class="textarea" placeholder="对外备注" />
|
<textarea v-model="externalRemark" class="textarea" :disabled="isTaskReadonly" placeholder="对外备注" />
|
||||||
<textarea v-model="internalRemark" class="textarea" placeholder="内部备注" />
|
<textarea v-model="internalRemark" class="textarea" :disabled="isTaskReadonly" placeholder="内部备注" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="detail.appraisal_template?.key_points?.length" class="stack" style="margin-top: 20rpx">
|
<view v-if="detail.appraisal_template?.key_points?.length" class="stack" style="margin-top: 20rpx">
|
||||||
@@ -502,12 +685,14 @@ onShow(() => {
|
|||||||
<input
|
<input
|
||||||
:value="item.point_value"
|
:value="item.point_value"
|
||||||
class="field"
|
class="field"
|
||||||
|
:disabled="isTaskReadonly"
|
||||||
:placeholder="`${item.point_name} 值`"
|
:placeholder="`${item.point_name} 值`"
|
||||||
@input="updateTemplatePointFromInput(index, 'point_value', $event)"
|
@input="updateTemplatePointFromInput(index, 'point_value', $event)"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
:value="item.point_remark"
|
:value="item.point_remark"
|
||||||
class="textarea"
|
class="textarea"
|
||||||
|
:disabled="isTaskReadonly"
|
||||||
:placeholder="`${item.point_name} 说明`"
|
:placeholder="`${item.point_name} 说明`"
|
||||||
@input="updateTemplatePointFromInput(index, 'point_remark', $event)"
|
@input="updateTemplatePointFromInput(index, 'point_remark', $event)"
|
||||||
/>
|
/>
|
||||||
@@ -515,15 +700,34 @@ onShow(() => {
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="card-desc evidence-title">证据附件</view>
|
<view class="card-desc evidence-title">证据附件</view>
|
||||||
<view v-if="evidenceFiles.length" class="list" style="margin-top: 14rpx">
|
<view v-if="evidenceFiles.length" class="attachment-grid">
|
||||||
<view v-for="item in evidenceFiles" :key="item.file_url" class="list-card">
|
<view v-for="item in evidenceFiles" :key="item.file_url" class="attachment-tile">
|
||||||
<view class="row">
|
<view class="attachment-preview" @click="previewAttachment(evidenceFiles, item)">
|
||||||
<view class="list-title">{{ item.name || item.file_id }}</view>
|
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||||
<text class="tag tag--danger" @click="removeEvidenceFile(item.file_url)">删除</text>
|
<video
|
||||||
|
v-else-if="isVideoAsset(item)"
|
||||||
|
class="attachment-thumb attachment-video-thumb"
|
||||||
|
:src="item.file_url"
|
||||||
|
:controls="false"
|
||||||
|
:muted="true"
|
||||||
|
:show-center-play-btn="false"
|
||||||
|
:enable-progress-gesture="false"
|
||||||
|
object-fit="cover"
|
||||||
|
@click.stop="previewAttachment(evidenceFiles, item)"
|
||||||
|
/>
|
||||||
|
<view v-else class="attachment-file-thumb">附件</view>
|
||||||
|
<view v-if="isVideoAsset(item)" class="attachment-play">▶</view>
|
||||||
|
</view>
|
||||||
|
<view class="attachment-meta">
|
||||||
|
<view class="attachment-name">{{ item.name || item.file_id }}</view>
|
||||||
|
<view class="attachment-actions">
|
||||||
|
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
|
||||||
|
<text v-if="!isTaskReadonly" class="attachment-remove" @click.stop="removeEvidenceFile(item.file_url)">删除</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="upload-actions">
|
</view>
|
||||||
|
<view v-if="!isTaskReadonly" class="upload-actions">
|
||||||
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceImage">
|
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceImage">
|
||||||
<text class="action-symbol">+</text>
|
<text class="action-symbol">+</text>
|
||||||
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
||||||
@@ -534,7 +738,7 @@ onShow(() => {
|
|||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-actions">
|
<view v-if="!isTaskReadonly" class="form-actions">
|
||||||
<button class="form-action form-action--secondary" :disabled="submitting" @click="submitResult('save')">保存</button>
|
<button class="form-action form-action--secondary" :disabled="submitting" @click="submitResult('save')">保存</button>
|
||||||
<button class="form-action form-action--primary" :disabled="submitting" @click="submitResult('submit')">提交</button>
|
<button class="form-action form-action--primary" :disabled="submitting" @click="submitResult('submit')">提交</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -543,19 +747,19 @@ onShow(() => {
|
|||||||
<view v-else-if="activeSection === 'supplement'" class="card">
|
<view v-else-if="activeSection === 'supplement'" class="card">
|
||||||
<view class="card-title">补资料</view>
|
<view class="card-title">补资料</view>
|
||||||
<view class="stack" style="margin-top: 18rpx">
|
<view class="stack" style="margin-top: 18rpx">
|
||||||
<textarea v-model="supplementForm.reason" class="textarea" placeholder="补资料原因" />
|
<textarea v-model="supplementForm.reason" class="textarea" :disabled="isTaskReadonly" placeholder="补资料原因" />
|
||||||
<input v-model="supplementForm.deadline" class="field" placeholder="截止时间(可选)" />
|
<input v-model="supplementForm.deadline" class="field" :disabled="isTaskReadonly" placeholder="截止时间(可选)" />
|
||||||
<view v-for="(item, index) in supplementForm.items" :key="index" class="stack">
|
<view v-for="(item, index) in supplementForm.items" :key="index" class="stack">
|
||||||
<input v-model="item.item_name" class="field" placeholder="补资料项名称" />
|
<input v-model="item.item_name" class="field" :disabled="isTaskReadonly" placeholder="补资料项名称" />
|
||||||
<textarea v-model="item.guide_text" class="textarea" placeholder="补资料说明" />
|
<textarea v-model="item.guide_text" class="textarea" :disabled="isTaskReadonly" placeholder="补资料说明" />
|
||||||
<view class="row">
|
<view v-if="!isTaskReadonly" class="row">
|
||||||
<text class="tag" :class="item.is_required ? 'tag--warning' : ''" @click="item.is_required = !item.is_required">
|
<text class="tag" :class="item.is_required ? 'tag--warning' : ''" @click="item.is_required = !item.is_required">
|
||||||
{{ item.is_required ? "必传" : "选传" }}
|
{{ item.is_required ? "必传" : "选传" }}
|
||||||
</text>
|
</text>
|
||||||
<text class="tag tag--danger" @click="removeSupplementItem(index)">删除</text>
|
<text class="tag tag--danger" @click="removeSupplementItem(index)">删除</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="form-actions">
|
<view v-if="!isTaskReadonly" class="form-actions">
|
||||||
<button class="form-action form-action--secondary" @click="addSupplementItem">添加一项</button>
|
<button class="form-action form-action--secondary" @click="addSupplementItem">添加一项</button>
|
||||||
<button class="form-action form-action--primary" :disabled="supplementSubmitting" @click="submitSupplement">发起补资料</button>
|
<button class="form-action form-action--primary" :disabled="supplementSubmitting" @click="submitSupplement">发起补资料</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -565,16 +769,35 @@ onShow(() => {
|
|||||||
<view v-else class="card">
|
<view v-else class="card">
|
||||||
<view class="card-title">中检报告</view>
|
<view class="card-title">中检报告</view>
|
||||||
<view class="stack" style="margin-top: 18rpx">
|
<view class="stack" style="margin-top: 18rpx">
|
||||||
<input v-model="zhongjianReportNo" class="field" placeholder="中检报告编号" />
|
<input v-model="zhongjianReportNo" class="field" :disabled="isTaskReadonly" placeholder="中检报告编号" />
|
||||||
<view v-if="zhongjianFiles.length" class="list">
|
<view v-if="zhongjianFiles.length" class="attachment-grid">
|
||||||
<view v-for="item in zhongjianFiles" :key="item.file_url" class="list-card">
|
<view v-for="item in zhongjianFiles" :key="item.file_url" class="attachment-tile">
|
||||||
<view class="row">
|
<view class="attachment-preview" @click="previewAttachment(zhongjianFiles, item)">
|
||||||
<view class="list-title">{{ item.name || item.file_id }}</view>
|
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
|
||||||
<text class="tag tag--danger" @click="removeZhongjianFile(item.file_url)">删除</text>
|
<video
|
||||||
|
v-else-if="isVideoAsset(item)"
|
||||||
|
class="attachment-thumb attachment-video-thumb"
|
||||||
|
:src="item.file_url"
|
||||||
|
:controls="false"
|
||||||
|
:muted="true"
|
||||||
|
:show-center-play-btn="false"
|
||||||
|
:enable-progress-gesture="false"
|
||||||
|
object-fit="cover"
|
||||||
|
@click.stop="previewAttachment(zhongjianFiles, item)"
|
||||||
|
/>
|
||||||
|
<view v-else class="attachment-file-thumb">附件</view>
|
||||||
|
<view v-if="isVideoAsset(item)" class="attachment-play">▶</view>
|
||||||
|
</view>
|
||||||
|
<view class="attachment-meta">
|
||||||
|
<view class="attachment-name">{{ item.name || item.file_id }}</view>
|
||||||
|
<view class="attachment-actions">
|
||||||
|
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
|
||||||
|
<text v-if="!isTaskReadonly" class="attachment-remove" @click.stop="removeZhongjianFile(item.file_url)">删除</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="upload-actions">
|
</view>
|
||||||
|
<view v-if="!isTaskReadonly" class="upload-actions">
|
||||||
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseZhongjianImage">
|
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseZhongjianImage">
|
||||||
<text class="action-symbol">+</text>
|
<text class="action-symbol">+</text>
|
||||||
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
|
||||||
@@ -584,13 +807,23 @@ onShow(() => {
|
|||||||
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
|
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<view class="form-actions" :class="detail.report_summary?.id ? '' : 'form-actions--single'">
|
<view v-if="!isTaskReadonly || detail.report_summary?.id" class="form-actions" :class="detail.report_summary?.id && !isTaskReadonly ? '' : 'form-actions--single'">
|
||||||
<button class="form-action form-action--primary" :disabled="submitting" @click="submitZhongjianReport">提交并发布</button>
|
<button v-if="!isTaskReadonly" class="form-action form-action--primary" :disabled="submitting" @click="submitZhongjianReport">提交并发布</button>
|
||||||
<button v-if="detail.report_summary?.id" class="form-action form-action--secondary" @click="openReportDetail">查看报告</button>
|
<button v-if="detail.report_summary?.id" class="form-action form-action--secondary" @click="openReportDetail">查看报告</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="activePreviewVideo" class="video-preview-mask" @click="closePreviewVideo">
|
||||||
|
<view class="video-preview-panel" @click.stop>
|
||||||
|
<view class="video-preview-head">
|
||||||
|
<text class="video-preview-title">{{ activePreviewVideo.name || "附件视频" }}</text>
|
||||||
|
<text class="video-preview-close" @click="closePreviewVideo">关闭</text>
|
||||||
|
</view>
|
||||||
|
<video class="video-preview-player" :src="activePreviewVideo.file_url" controls autoplay />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="card-title">任务信息</view>
|
<view class="card-title">任务信息</view>
|
||||||
<view class="meta-grid">
|
<view class="meta-grid">
|
||||||
@@ -610,6 +843,10 @@ onShow(() => {
|
|||||||
<view class="meta-label">处理人</view>
|
<view class="meta-label">处理人</view>
|
||||||
<view class="meta-value">{{ detail.task_info.assignee_name }}</view>
|
<view class="meta-value">{{ detail.task_info.assignee_name }}</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="internalTagNo" class="meta-item meta-item--wide">
|
||||||
|
<view class="meta-label">流转码编号</view>
|
||||||
|
<view class="meta-value transfer-code-value">{{ internalTagNo }}</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -622,12 +859,137 @@ onShow(() => {
|
|||||||
gap: 14rpx;
|
gap: 14rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.readonly-notice {
|
||||||
|
margin: -6rpx 0 18rpx;
|
||||||
|
padding: 18rpx 22rpx;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-code-value {
|
||||||
|
color: var(--work-warning);
|
||||||
|
font-weight: 900;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.evidence-title {
|
.evidence-title {
|
||||||
margin-top: 24rpx;
|
margin-top: 24rpx;
|
||||||
color: var(--work-text);
|
color: var(--work-text);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachment-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-tile {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--work-border);
|
||||||
|
border-radius: var(--work-radius-sm);
|
||||||
|
background: var(--work-card-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumb {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-video-thumb {
|
||||||
|
background: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file-thumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--work-text-soft);
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-play {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 54rpx;
|
||||||
|
height: 54rpx;
|
||||||
|
margin-left: -27rpx;
|
||||||
|
margin-top: -27rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(32, 33, 36, 0.72);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 54rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-meta {
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-type,
|
||||||
|
.attachment-remove {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 34rpx;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
border-radius: var(--work-radius-pill);
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 34rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-type {
|
||||||
|
background: var(--work-info-soft);
|
||||||
|
color: var(--work-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-remove {
|
||||||
|
background: var(--work-danger-soft);
|
||||||
|
color: var(--work-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.upload-actions {
|
.upload-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -702,4 +1064,54 @@ onShow(() => {
|
|||||||
background: var(--work-accent);
|
background: var(--work-accent);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-preview-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-panel {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--work-radius);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
padding: 22rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--work-info);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview-player {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 58vh;
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const tasks = ref<AdminAppraisalTaskListItem[]>([]);
|
|||||||
|
|
||||||
const isWarehouse = computed(() => role.value === "warehouse");
|
const isWarehouse = computed(() => role.value === "warehouse");
|
||||||
const title = computed(() => (isWarehouse.value ? "订单中心" : "鉴定工单"));
|
const title = computed(() => (isWarehouse.value ? "订单中心" : "鉴定工单"));
|
||||||
const desc = computed(() => (isWarehouse.value ? "仅展示在途、已入仓、待寄回订单。" : "处理我的鉴定待办和历史任务。"));
|
const desc = computed(() => (isWarehouse.value ? "仅展示待入库、在途、已入仓、待寄回订单。" : "处理我的鉴定待办和历史任务。"));
|
||||||
const listCount = computed(() => (isWarehouse.value ? orders.value.length : tasks.value.length));
|
const listCount = computed(() => (isWarehouse.value ? orders.value.length : tasks.value.length));
|
||||||
const hasMore = computed(() => total.value > listCount.value);
|
const hasMore = computed(() => total.value > listCount.value);
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ const statusOptions = computed(() =>
|
|||||||
isWarehouse.value
|
isWarehouse.value
|
||||||
? [
|
? [
|
||||||
{ label: "全部", value: "warehouse_active" },
|
{ label: "全部", value: "warehouse_active" },
|
||||||
|
{ label: "待入库", value: "warehouse_pending_inbound" },
|
||||||
{ label: "在途", value: "warehouse_in_transit" },
|
{ label: "在途", value: "warehouse_in_transit" },
|
||||||
{ label: "已入仓", value: "warehouse_received" },
|
{ label: "已入仓", value: "warehouse_received" },
|
||||||
{ label: "待寄回", value: "warehouse_pending_return" },
|
{ label: "待寄回", value: "warehouse_pending_return" },
|
||||||
@@ -112,6 +113,10 @@ function openOrder(item: AdminOrderListItem) {
|
|||||||
uni.navigateTo({ url: `/pages/order/detail?id=${item.id}` });
|
uni.navigateTo({ url: `/pages/order/detail?id=${item.id}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openManualOrderCreate() {
|
||||||
|
uni.navigateTo({ url: "/pages/order/manual-create" });
|
||||||
|
}
|
||||||
|
|
||||||
function openTask(item: AdminAppraisalTaskListItem) {
|
function openTask(item: AdminAppraisalTaskListItem) {
|
||||||
uni.navigateTo({ url: `/pages/task/detail?id=${item.id}` });
|
uni.navigateTo({ url: `/pages/task/detail?id=${item.id}` });
|
||||||
}
|
}
|
||||||
@@ -134,6 +139,7 @@ onReachBottom(loadMore);
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="card">
|
<view class="card">
|
||||||
|
<button v-if="isWarehouse" class="btn btn--primary manual-entry" @click="openManualOrderCreate">补录订单</button>
|
||||||
<input v-model="keyword" class="field" :placeholder="isWarehouse ? '搜索订单号 / 鉴定单号 / 商品名称' : '搜索订单号 / 外部订单号 / 商品名称'" @confirm="handleSearch" />
|
<input v-model="keyword" class="field" :placeholder="isWarehouse ? '搜索订单号 / 鉴定单号 / 商品名称' : '搜索订单号 / 外部订单号 / 商品名称'" @confirm="handleSearch" />
|
||||||
<scroll-view class="status-scroll" scroll-x>
|
<scroll-view class="status-scroll" scroll-x>
|
||||||
<view class="status-row">
|
<view class="status-row">
|
||||||
@@ -159,6 +165,10 @@ onReachBottom(loadMore);
|
|||||||
<text class="tag">{{ item.warehouse_bucket_text || item.display_status }}</text>
|
<text class="tag">{{ item.warehouse_bucket_text || item.display_status }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="list-subtitle">{{ item.order_no }} / {{ item.appraisal_no }}</view>
|
<view class="list-subtitle">{{ item.order_no }} / {{ item.appraisal_no }}</view>
|
||||||
|
<view v-if="item.internal_tag_no" class="transfer-code">
|
||||||
|
<text class="transfer-code__label">流转码</text>
|
||||||
|
<text class="transfer-code__value">{{ item.internal_tag_no }}</text>
|
||||||
|
</view>
|
||||||
<view class="list-footer">
|
<view class="list-footer">
|
||||||
<text class="tag">{{ item.service_provider_text }}</text>
|
<text class="tag">{{ item.service_provider_text }}</text>
|
||||||
<text class="list-subtitle">{{ item.created_at }}</text>
|
<text class="list-subtitle">{{ item.created_at }}</text>
|
||||||
@@ -174,6 +184,10 @@ onReachBottom(loadMore);
|
|||||||
<text :class="['tag', item.status === 'completed' ? 'tag--success' : item.status === 'returned' ? 'tag--warning' : '']">{{ item.status_text }}</text>
|
<text :class="['tag', item.status === 'completed' ? 'tag--success' : item.status === 'returned' ? 'tag--warning' : '']">{{ item.status_text }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="list-subtitle">{{ item.order_no }} / {{ item.external_order_no || item.appraisal_no }}</view>
|
<view class="list-subtitle">{{ item.order_no }} / {{ item.external_order_no || item.appraisal_no }}</view>
|
||||||
|
<view v-if="item.internal_tag_no" class="transfer-code">
|
||||||
|
<text class="transfer-code__label">流转码</text>
|
||||||
|
<text class="transfer-code__value">{{ item.internal_tag_no }}</text>
|
||||||
|
</view>
|
||||||
<view class="list-footer">
|
<view class="list-footer">
|
||||||
<text class="tag">{{ item.service_provider_text }}</text>
|
<text class="tag">{{ item.service_provider_text }}</text>
|
||||||
<text class="list-subtitle">{{ item.assignee_name }}</text>
|
<text class="list-subtitle">{{ item.assignee_name }}</text>
|
||||||
@@ -187,6 +201,10 @@ onReachBottom(loadMore);
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.manual-entry {
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.status-scroll {
|
.status-scroll {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 18rpx;
|
margin-top: 18rpx;
|
||||||
@@ -215,6 +233,38 @@ onReachBottom(loadMore);
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transfer-code {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 42rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--work-radius-pill);
|
||||||
|
background: var(--work-warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-code__label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
color: var(--work-warning);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 42rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-code__value {
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 14rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--work-text);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 42rpx;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.load-more {
|
.load-more {
|
||||||
margin-top: 22rpx;
|
margin-top: 22rpx;
|
||||||
padding: 22rpx;
|
padding: 22rpx;
|
||||||
|
|||||||
10
work-app/src/static/regions/README.md
Normal file
10
work-app/src/static/regions/README.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Regions Data
|
||||||
|
|
||||||
|
`pca.json` stores province/city/district data for the address picker.
|
||||||
|
|
||||||
|
- Source package: `lcn@7.2.2`
|
||||||
|
- Upstream source: 2024 Ministry of Civil Affairs county-level-and-above administrative division codes
|
||||||
|
- Data scope: 34 province-level entries, 342 prefecture-level entries, 2849 county-level entries
|
||||||
|
- License: MIT, inherited from `lcn`
|
||||||
|
|
||||||
|
To update this file later, replace `pca.json` with the latest `lcn` `data/pca.json` output and rerun `npm run type-check` plus `npm run build:h5`.
|
||||||
1
work-app/src/static/regions/pca.json
Normal file
1
work-app/src/static/regions/pca.json
Normal file
File diff suppressed because one or more lines are too long
106
work-app/src/utils/regions.ts
Normal file
106
work-app/src/utils/regions.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import regionSource from "../static/regions/pca.json";
|
||||||
|
|
||||||
|
export type RegionNode = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
children?: RegionNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegionSelection = [string, string, string];
|
||||||
|
|
||||||
|
export type RegionColumnIndex = 0 | 1 | 2;
|
||||||
|
|
||||||
|
export type RegionColumnChange = {
|
||||||
|
column: RegionColumnIndex;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegionPickerState = {
|
||||||
|
columns: [string[], string[], string[]];
|
||||||
|
indexes: [number, number, number];
|
||||||
|
selection: RegionSelection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const regionTree = regionSource as RegionNode[];
|
||||||
|
|
||||||
|
function getChildren(node?: RegionNode) {
|
||||||
|
return node?.children || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampIndex(index: number, length: number) {
|
||||||
|
if (length <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(index)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.min(Math.max(Math.trunc(index), 0), length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function names(nodes: RegionNode[]) {
|
||||||
|
return nodes.map((item) => item.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstAvailableSelection(province: RegionNode, city?: RegionNode, district?: RegionNode): RegionSelection {
|
||||||
|
const cityName = city?.name || province.name;
|
||||||
|
const districtName = district?.name || city?.name || province.name;
|
||||||
|
return [province.name, cityName, districtName];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRegionIndexes(selection: Partial<Record<"province" | "city" | "district", string>>): [number, number, number] {
|
||||||
|
const provinceIndex = Math.max(
|
||||||
|
0,
|
||||||
|
regionTree.findIndex((province) => province.name === selection.province),
|
||||||
|
);
|
||||||
|
const province = regionTree[provinceIndex] || regionTree[0];
|
||||||
|
const cities = getChildren(province);
|
||||||
|
const cityIndex = Math.max(
|
||||||
|
0,
|
||||||
|
cities.findIndex((city) => city.name === selection.city),
|
||||||
|
);
|
||||||
|
const city = cities[cityIndex];
|
||||||
|
const districts = getChildren(city);
|
||||||
|
const districtIndex = Math.max(
|
||||||
|
0,
|
||||||
|
districts.findIndex((district) => district.name === selection.district),
|
||||||
|
);
|
||||||
|
return [provinceIndex, cityIndex, districtIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRegionPickerState(indexes: [number, number, number]): RegionPickerState {
|
||||||
|
const provinceIndex = clampIndex(indexes[0], regionTree.length);
|
||||||
|
const province = regionTree[provinceIndex] || regionTree[0];
|
||||||
|
const cities = getChildren(province);
|
||||||
|
const cityIndex = clampIndex(indexes[1], cities.length);
|
||||||
|
const city = cities[cityIndex];
|
||||||
|
const districts = getChildren(city);
|
||||||
|
const districtIndex = clampIndex(indexes[2], districts.length);
|
||||||
|
const district = districts[districtIndex];
|
||||||
|
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
names(regionTree),
|
||||||
|
cities.length ? names(cities) : [province.name],
|
||||||
|
districts.length ? names(districts) : [city?.name || province.name],
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
provinceIndex,
|
||||||
|
cities.length ? cityIndex : 0,
|
||||||
|
districts.length ? districtIndex : 0,
|
||||||
|
],
|
||||||
|
selection: firstAvailableSelection(province, city, district),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRegionPickerIndexes(
|
||||||
|
indexes: [number, number, number],
|
||||||
|
change: RegionColumnChange,
|
||||||
|
): [number, number, number] {
|
||||||
|
if (change.column === 0) {
|
||||||
|
return [change.value, 0, 0];
|
||||||
|
}
|
||||||
|
if (change.column === 1) {
|
||||||
|
return [indexes[0], change.value, 0];
|
||||||
|
}
|
||||||
|
return [indexes[0], indexes[1], change.value];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user