feat: 打通业务员端创建订单链路
This commit is contained in:
parent
afe6993929
commit
8b7d1379d1
@ -19,13 +19,13 @@ class OrderItemPayload(BaseModel):
|
||||
product_name: str
|
||||
specification: str
|
||||
unit: str
|
||||
quantity: float
|
||||
sale_price: float
|
||||
cost_price: float
|
||||
rebate_amount: float = 0
|
||||
freight_amount: float = 0
|
||||
tax_amount: float = 0
|
||||
other_fee_amount: float = 0
|
||||
quantity: float = Field(gt=0)
|
||||
sale_price: float = Field(ge=0)
|
||||
cost_price: float = Field(ge=0)
|
||||
rebate_amount: float = Field(default=0, ge=0)
|
||||
freight_amount: float = Field(default=0, ge=0)
|
||||
tax_amount: float = Field(default=0, ge=0)
|
||||
other_fee_amount: float = Field(default=0, ge=0)
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
@ -36,11 +36,11 @@ class CreateOrderRequest(BaseModel):
|
||||
order_source: str | None = None
|
||||
delivery_type: str | None = None
|
||||
factory_id: int | None = None
|
||||
commission_amount: float = 0
|
||||
rebate_total: float = 0
|
||||
freight_total: float = 0
|
||||
tax_total: float = 0
|
||||
other_fee_total: float = 0
|
||||
commission_amount: float = Field(default=0, ge=0)
|
||||
rebate_total: float = Field(default=0, ge=0)
|
||||
freight_total: float = Field(default=0, ge=0)
|
||||
tax_total: float = Field(default=0, ge=0)
|
||||
other_fee_total: float = Field(default=0, ge=0)
|
||||
remark: str | None = None
|
||||
items: list[OrderItemPayload] = Field(default_factory=list)
|
||||
|
||||
|
||||
@ -346,6 +346,11 @@ class OrderService:
|
||||
def create_order(self, payload: dict, session: Session | None = None) -> dict:
|
||||
if session is not None:
|
||||
try:
|
||||
if not payload["customer_name"].strip():
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="客户姓名不能为空", status_code=400)
|
||||
if not payload["customer_mobile"].strip():
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="客户手机号不能为空", status_code=400)
|
||||
|
||||
customer = self.customer_repository.find_by_name_and_mobile(
|
||||
session,
|
||||
payload["customer_name"],
|
||||
@ -368,6 +373,9 @@ class OrderService:
|
||||
)
|
||||
|
||||
items = payload["items"]
|
||||
if not items:
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="订单明细不能为空", status_code=400)
|
||||
|
||||
sale_total = sum(item["quantity"] * item["sale_price"] for item in items)
|
||||
cost_total = sum(item["quantity"] * item["cost_price"] for item in items)
|
||||
profit_total = (
|
||||
|
||||
@ -92,6 +92,13 @@ export function fetchOrderForm() {
|
||||
return wait({ sections: orderFormSections });
|
||||
}
|
||||
|
||||
export async function createOrder(payload) {
|
||||
return request("/api/orders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchOrderDetail(orderId) {
|
||||
try {
|
||||
const data = await request(`/api/orders/${orderId}`);
|
||||
|
||||
@ -1,63 +1,245 @@
|
||||
<template>
|
||||
<section class="page">
|
||||
<h2>订单录入 / 编辑页</h2>
|
||||
<div class="tips">
|
||||
<span>规则:客户姓名、客户手机号必填</span>
|
||||
<span>规则:至少保留一条明细</span>
|
||||
<span>规则:数量必须大于 0</span>
|
||||
<h2>新建订单</h2>
|
||||
<p class="tips">当前先接入业务员端最小可用创建流程,后续再扩展多明细和更多字段。</p>
|
||||
|
||||
<div v-if="message" class="message-box" :class="{ error: messageType === 'error' }">
|
||||
{{ message }}
|
||||
</div>
|
||||
<p v-if="loading" class="loading">加载中...</p>
|
||||
<div v-else class="grid">
|
||||
<article v-for="section in orderFormSections" :key="section.title" class="card">
|
||||
<h3>{{ section.title }}</h3>
|
||||
<ul>
|
||||
<li v-for="field in section.fields" :key="field">{{ field }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
<section v-if="!loading" class="actions">
|
||||
<button>保存草稿</button>
|
||||
<button disabled>保存并提交</button>
|
||||
<button>新增明细</button>
|
||||
|
||||
<form class="form-grid" @submit.prevent="handleSubmit">
|
||||
<label>
|
||||
<span>客户姓名</span>
|
||||
<input v-model.trim="form.customer_name" type="text" placeholder="请输入客户姓名" />
|
||||
</label>
|
||||
<label>
|
||||
<span>客户手机号</span>
|
||||
<input v-model.trim="form.customer_mobile" type="text" placeholder="请输入客户手机号" />
|
||||
</label>
|
||||
<label>
|
||||
<span>客户地址</span>
|
||||
<input v-model.trim="form.customer_address" type="text" placeholder="请输入客户地址" />
|
||||
</label>
|
||||
<label>
|
||||
<span>订单来源</span>
|
||||
<input v-model.trim="form.order_source" type="text" placeholder="例如:线下拜访" />
|
||||
</label>
|
||||
<label>
|
||||
<span>配送方式</span>
|
||||
<input v-model.trim="form.delivery_type" type="text" placeholder="例如:工厂直送" />
|
||||
</label>
|
||||
<label>
|
||||
<span>工厂ID</span>
|
||||
<input v-model.number="form.factory_id" type="number" min="1" placeholder="请输入工厂ID" />
|
||||
</label>
|
||||
<label>
|
||||
<span>提成金额</span>
|
||||
<input v-model.number="form.commission_amount" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>返利合计</span>
|
||||
<input v-model.number="form.rebate_total" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>运费合计</span>
|
||||
<input v-model.number="form.freight_total" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>税费合计</span>
|
||||
<input v-model.number="form.tax_total" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>其他费用</span>
|
||||
<input v-model.number="form.other_fee_total" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label class="full-width">
|
||||
<span>备注</span>
|
||||
<textarea v-model.trim="form.remark" rows="3" placeholder="请输入订单备注"></textarea>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<section class="item-panel">
|
||||
<div class="panel-header">
|
||||
<h3>订单明细</h3>
|
||||
<span>当前先维护 1 条明细</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>产品名称</span>
|
||||
<input v-model.trim="item.product_name" type="text" placeholder="请输入产品名称" />
|
||||
</label>
|
||||
<label>
|
||||
<span>规格</span>
|
||||
<input v-model.trim="item.specification" type="text" placeholder="请输入规格" />
|
||||
</label>
|
||||
<label>
|
||||
<span>单位</span>
|
||||
<input v-model.trim="item.unit" type="text" placeholder="例如:吨" />
|
||||
</label>
|
||||
<label>
|
||||
<span>数量</span>
|
||||
<input v-model.number="item.quantity" type="number" min="0.01" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>销售单价</span>
|
||||
<input v-model.number="item.sale_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
<span>成本单价</span>
|
||||
<input v-model.number="item.cost_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="!loading" class="summary">
|
||||
<div>销售金额汇总:100.00</div>
|
||||
<div>成本金额汇总:60.00</div>
|
||||
<div>利润预览:20.00</div>
|
||||
|
||||
<section class="summary">
|
||||
<div>销售额:{{ saleTotal.toFixed(2) }}</div>
|
||||
<div>成本额:{{ costTotal.toFixed(2) }}</div>
|
||||
<div>预估利润:{{ profitTotal.toFixed(2) }}</div>
|
||||
</section>
|
||||
|
||||
<section class="actions">
|
||||
<button type="button" class="secondary-btn" @click="resetForm">重置表单</button>
|
||||
<button type="button" class="secondary-btn" @click="fillDemoData">填充演示数据</button>
|
||||
<button type="submit" :disabled="submitting" @click="handleSubmit">
|
||||
{{ submitting ? "创建中..." : "创建订单" }}
|
||||
</button>
|
||||
</section>
|
||||
<p v-if="!loading" class="validation">当前为演示状态:提交按钮默认禁用,代表必填项和金额校验未全部通过。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { fetchOrderForm } from "../mockApi";
|
||||
import { createOrder } from "../mockApi";
|
||||
|
||||
const loading = ref(true);
|
||||
const orderFormSections = ref([]);
|
||||
const router = useRouter();
|
||||
const submitting = ref(false);
|
||||
const message = ref("");
|
||||
const messageType = ref("success");
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await fetchOrderForm();
|
||||
orderFormSections.value = data.sections;
|
||||
loading.value = false;
|
||||
});
|
||||
function buildDefaultForm() {
|
||||
return {
|
||||
customer_name: "",
|
||||
customer_mobile: "",
|
||||
customer_address: "",
|
||||
order_source: "",
|
||||
delivery_type: "",
|
||||
factory_id: 1001,
|
||||
commission_amount: 0,
|
||||
rebate_total: 0,
|
||||
freight_total: 0,
|
||||
tax_total: 0,
|
||||
other_fee_total: 0,
|
||||
remark: "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultItem() {
|
||||
return {
|
||||
product_name: "",
|
||||
specification: "",
|
||||
unit: "",
|
||||
quantity: 1,
|
||||
sale_price: 0,
|
||||
cost_price: 0,
|
||||
rebate_amount: 0,
|
||||
freight_amount: 0,
|
||||
tax_amount: 0,
|
||||
other_fee_amount: 0,
|
||||
remark: "",
|
||||
};
|
||||
}
|
||||
|
||||
const form = reactive(buildDefaultForm());
|
||||
const item = reactive(buildDefaultItem());
|
||||
|
||||
const saleTotal = computed(() => Number(item.quantity || 0) * Number(item.sale_price || 0));
|
||||
const costTotal = computed(() => Number(item.quantity || 0) * Number(item.cost_price || 0));
|
||||
const profitTotal = computed(
|
||||
() =>
|
||||
saleTotal.value -
|
||||
costTotal.value -
|
||||
Number(form.rebate_total || 0) -
|
||||
Number(form.freight_total || 0) -
|
||||
Number(form.tax_total || 0) -
|
||||
Number(form.other_fee_total || 0),
|
||||
);
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, buildDefaultForm());
|
||||
Object.assign(item, buildDefaultItem());
|
||||
message.value = "";
|
||||
}
|
||||
|
||||
function fillDemoData() {
|
||||
Object.assign(form, {
|
||||
customer_name: "演示客户",
|
||||
customer_mobile: "13900000000",
|
||||
customer_address: "杭州市西湖区演示地址 1 号",
|
||||
order_source: "线下拜访",
|
||||
delivery_type: "工厂直送",
|
||||
factory_id: 1001,
|
||||
commission_amount: 50,
|
||||
rebate_total: 5,
|
||||
freight_total: 10,
|
||||
tax_total: 3,
|
||||
other_fee_total: 2,
|
||||
remark: "前端联调演示订单",
|
||||
});
|
||||
Object.assign(item, {
|
||||
product_name: "演示产品A",
|
||||
specification: "10kg",
|
||||
unit: "吨",
|
||||
quantity: 1,
|
||||
sale_price: 100,
|
||||
cost_price: 60,
|
||||
rebate_amount: 5,
|
||||
freight_amount: 10,
|
||||
tax_amount: 3,
|
||||
other_fee_amount: 2,
|
||||
remark: "演示明细",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
submitting.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
const result = await createOrder({
|
||||
...form,
|
||||
items: [{ ...item }],
|
||||
});
|
||||
message.value = `订单 ${result.order_no} 创建成功,当前状态:${result.order_status}`;
|
||||
messageType.value = "success";
|
||||
await router.push(`/orders/${result.order_id}`);
|
||||
} catch (error) {
|
||||
message.value = error.message || "创建订单失败";
|
||||
messageType.value = "error";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff; border-radius: 8px; padding: 20px; }
|
||||
h2 { margin: 0 0 12px; }
|
||||
.tips { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
|
||||
.tips span { padding: 6px 10px; border-radius: 999px; background: #fff7ed; color: #c2410c; font-size: 13px; }
|
||||
.loading { margin: 0; color: #6b7280; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
|
||||
h3 { margin: 0 0 10px; font-size: 16px; }
|
||||
ul { margin: 0; padding-left: 18px; }
|
||||
li + li { margin-top: 8px; }
|
||||
.tips { margin: 0 0 16px; color: #6b7280; }
|
||||
.message-box { margin-bottom: 16px; padding: 12px 14px; border-radius: 8px; background: #dcfce7; color: #166534; }
|
||||
.message-box.error { background: #fee2e2; color: #b91c1c; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
label { display: grid; gap: 6px; }
|
||||
label span { color: #4b5563; font-size: 13px; }
|
||||
input, textarea { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 12px; background: #fff; }
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
.item-panel { margin-top: 20px; padding: 16px; border: 1px solid #e5e7eb; border-radius: 8px; }
|
||||
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.panel-header h3 { margin: 0; font-size: 16px; }
|
||||
.panel-header span { color: #6b7280; font-size: 13px; }
|
||||
.summary { display: flex; gap: 20px; margin-top: 20px; padding: 14px; border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb; }
|
||||
.actions { display: flex; gap: 12px; margin-top: 20px; }
|
||||
button { border: 1px solid #d1d5db; background: #fff; border-radius: 8px; padding: 10px 14px; }
|
||||
button:disabled { color: #9ca3af; background: #f3f4f6; }
|
||||
.summary { display: flex; gap: 20px; margin-top: 20px; padding: 14px; border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb; }
|
||||
.validation { margin: 16px 0 0; color: #6b7280; }
|
||||
.secondary-btn { color: #374151; }
|
||||
</style>
|
||||
|
||||
@ -95,3 +95,4 @@
|
||||
| 2026-05-14 | 已继续推进审计查询主线:`/api/audit-logs` 已支持按 `biz_type/biz_id/operate_type/start_time/end_time/page_no/page_size` 查询,并接入真实订单审批日志数据源。 |
|
||||
| 2026-05-14 | 已开始补前端真实联调:`frontend/web-sales` 的订单列表与订单详情已改为优先请求真实后端接口,失败时再回退 mock;同时清理了该模块关键页面中的乱码展示文本。 |
|
||||
| 2026-05-14 | 已继续按前后端同步方式推进:后端订单接口已清理真实中文提示语;前端 `web-sales` 订单列表页与详情页已接入真实 `提交审核/取消订单` 动作,并补充加载态、禁用态与操作结果提示。 |
|
||||
| 2026-05-14 | 已继续按前后端同步方式推进创建订单链路:后端补充创建订单参数校验;前端 `web-sales` 新建订单页已接入真实 `/api/orders` 创建接口,并支持最小可用单明细录入。 |
|
||||
|
||||
Loading…
Reference in New Issue
Block a user