feat: 打通业务员端创建订单链路

This commit is contained in:
wsb1224 2026-05-14 14:24:06 +08:00
parent afe6993929
commit 8b7d1379d1
5 changed files with 252 additions and 54 deletions

View File

@ -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)

View File

@ -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 = (

View File

@ -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}`);

View File

@ -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>

View File

@ -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` 创建接口,并支持最小可用单明细录入。 |