feat(order): 下单支持一口价/师傅报价竞价/用户自主定价三种方式

- 下单页新增计价方式选择;自主定价填写总价,师傅报价单进入报价大厅
- 用户订单详情展示师傅报价列表并可选定师傅;选定后同步师傅、金额与订单状态
- 师傅大厅新增用户定价筛选与按价接单;报价单禁止直接抢单
- 后端订单新增 custom 价态、金额口径统一由 calcAmount 输出
- 报价中状态纳入用户可取消范围;超时回流重新开放报价
- 新增 fa_order.price_type custom 枚举迁移脚本(schema_phase6)
这个提交包含在:
linda
2026-10-28 17:48:23 +08:00
父节点 3bba2d6e94
当前提交 e1bf139c5a
共有 11 个文件被更改,包括 500 次插入 和 50 次删除

查看文件

@@ -127,7 +127,7 @@ CREATE TABLE IF NOT EXISTS `fa_order` (
`service_id` int(10) unsigned NOT NULL DEFAULT '0',
`service_name` varchar(100) NOT NULL DEFAULT '' COMMENT '服务名称(快照)',
`service_image` varchar(255) NOT NULL DEFAULT '' COMMENT '服务主图(快照)',
`price_type` enum('fixed','quote','range') NOT NULL DEFAULT 'fixed',
`price_type` enum('fixed','quote','range','custom') NOT NULL DEFAULT 'fixed' COMMENT '一口价/报价竞价/区间/用户自主定价',
`goods_images` varchar(1000) NOT NULL DEFAULT '' COMMENT '商品图片(json)',
`goods_model` varchar(100) NOT NULL DEFAULT '' COMMENT '商品型号',
`goods_num` int(11) NOT NULL DEFAULT '1',

15
db/schema_phase6.sql 普通文件
查看文件

@@ -0,0 +1,15 @@
-- ============================================================================
-- 优匠优服 · Phase 6 下单三种计价方式
-- 一口价 fixed / 师傅报价竞价 quote / 用户自主定价 custom
-- fa_service 仍使用 fixed / quote / range(服务默认价态)
-- 下单时用户可覆盖 price_type,因此 fa_order 增加 custom
-- ============================================================================
SET NAMES utf8mb4;
ALTER TABLE `fa_order`
MODIFY COLUMN `price_type` enum('fixed','quote','range','custom') NOT NULL DEFAULT 'fixed'
COMMENT '一口价/师傅报价竞价/区间/用户自主定价';
-- 回滚(先确认没有 custom 订单):
-- SELECT COUNT(*) FROM fa_order WHERE price_type='custom';
-- ALTER TABLE fa_order MODIFY COLUMN price_type enum('fixed','quote','range') NOT NULL DEFAULT 'fixed';

查看文件

@@ -23,6 +23,8 @@
<view class="item" v-for="o in list" :key="o.id">
<view class="hd">
<text class="tag-urgent" v-if="Number(o.urgent)">急单</text>
<text class="tag-mode quote" v-if="o.price_type === 'quote'">师傅报价</text>
<text class="tag-mode custom" v-if="o.price_type === 'custom'">用户定价</text>
<text class="svc">{{ o.service_name }}</text>
<text class="amt">¥{{ o.pay_amount }}</text>
</view>
@@ -36,7 +38,7 @@
<text class="phone">{{ o.left_seconds > 0 ? ('剩余 ' + countdown(o.left_seconds)) : '即将关闭' }}</text>
<view class="ft-btns">
<view class="quote-btn" v-if="o.price_type === 'quote'" @tap="goQuote(o)">我要报价({{ o.quote_count }})</view>
<view class="grab" v-else @tap="grab(o)">立即抢单</view>
<view class="grab" v-else @tap="grab(o)">{{ o.price_type === 'custom' ? '按此价接单' : '立即抢单' }}</view>
</view>
</view>
</view>
@@ -64,7 +66,8 @@
const typeTabs = [
{ key: 'all', name: '全部订单' },
{ key: 'fixed', name: '一口价' },
{ key: 'quote', name: '报价订单' }
{ key: 'quote', name: '报价订单' },
{ key: 'custom', name: '用户定价' }
];
const sortTabs = [
{ key: 'new', name: '最新' },
@@ -147,6 +150,9 @@
.item { background: #fff; border-radius: 20rpx; margin-bottom: 20rpx; padding: 22rpx 24rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,0.06); }
.hd { display: flex; align-items: center; padding-bottom: 14rpx; border-bottom: 1rpx solid #F4F4F4; }
.tag-urgent { padding: 0 12rpx; line-height: 34rpx; border-radius: 8rpx; background: #FDEEEB; color: #E8331B; font-size: 20rpx; margin-right: 10rpx; }
.tag-mode { padding: 0 12rpx; line-height: 34rpx; border-radius: 8rpx; font-size: 20rpx; margin-right: 10rpx; }
.tag-mode.quote { background: #F0EDFF; color: #5B3FD8; }
.tag-mode.custom { background: #E9F7EE; color: #2FA84F; }
.svc { flex: 1; font-size: 30rpx; font-weight: 700; color: #1F1F1F; }
.amt { font-size: 32rpx; font-weight: 700; color: #F0710D; }
.bd { display: flex; flex-direction: column; padding: 16rpx 0; }

查看文件

@@ -6,11 +6,38 @@
<text class="svc-name">{{ svc ? svc.name : '' }}</text>
<text class="svc-price">{{ svc ? svc.price_text : '' }}</text>
</view>
<view class="urgent" :class="{ on: urgent }" @tap="urgent = !urgent">
<view class="urgent" :class="{ on: urgent }" v-if="priceType !== 'custom'" @tap="urgent = !urgent">
<text class="urgent-txt">{{ urgent ? '已加急 +20%' : '加急' }}</text>
</view>
</view>
<!-- 计价方式:一口价 / 师傅报价竞价 / 用户自主定价 -->
<view class="sec">
<view class="sec-title">选择下单方式</view>
<view class="modes">
<view
v-for="m in modes"
:key="m.key"
class="mode"
:class="{ on: priceType === m.key }"
@tap="selectMode(m.key)"
>
<text class="mode-name">{{ m.name }}</text>
<text class="mode-desc">{{ m.desc }}</text>
<view class="mode-check" v-if="priceType === m.key">✓</view>
</view>
</view>
<view class="mode-note">{{ modeNote }}</view>
<view class="fi" v-if="priceType === 'custom'">
<text class="fk">我的出价</text>
<view class="price-input-wrap">
<text class="price-unit">¥</text>
<input class="fv price-input" v-model="userPrice" type="digit" placeholder="请输入您愿意支付的总价" />
</view>
</view>
</view>
<view class="sec">
<view class="sec-title">服务地址</view>
<view class="addr" v-if="address">
@@ -61,22 +88,42 @@
</view>
<view class="sec" v-if="amount">
<view class="sec-title">费用明细</view>
<view class="li"><text class="k">基础价</text><text class="v">¥{{ amount.base_amount }}</text></view>
<view class="li"><text class="k">楼层费</text><text class="v">¥{{ amount.floor_amount }}</text></view>
<view class="li" v-if="amount.urgent_amount > 0"><text class="k">加急费</text><text class="v">¥{{ amount.urgent_amount }}</text></view>
<view class="li total"><text class="k">应付合计</text><text class="v big">¥{{ amount.pay_amount }}</text></view>
<view class="sec-title">{{ priceType === 'quote' ? '费用预估' : '费用明细' }}</view>
<view class="li">
<text class="k">{{ priceType === 'custom' ? '您的出价' : '基础价' }}</text>
<text class="v">¥{{ amount.base_amount }}</text>
</view>
<view class="li" v-if="priceType !== 'custom'">
<text class="k">楼层费</text>
<text class="v">¥{{ amount.floor_amount }}</text>
</view>
<view class="li" v-if="priceType !== 'custom' && amount.urgent_amount > 0">
<text class="k">加急费</text>
<text class="v">¥{{ amount.urgent_amount }}</text>
</view>
<view class="li" v-if="amount.discount_amount > 0">
<text class="k">优惠券抵扣</text>
<text class="v">-¥{{ amount.discount_amount }}</text>
</view>
<view class="li total">
<text class="k">{{ priceType === 'quote' ? '参考合计' : '应付合计' }}</text>
<text class="v big">¥{{ amount.pay_amount }}</text>
</view>
<text class="fee-note" v-if="priceType === 'quote'">先支付参考价,选定师傅报价后按最终报价结算</text>
<text class="fee-note" v-if="priceType === 'custom'">自主定价为含楼层/加急的总价,师傅接单即按此价服务</text>
</view>
<view class="bar">
<view class="bar-l"><text class="bar-k">合计</text><text class="bar-p">¥{{ amount ? amount.pay_amount : 0 }}</text></view>
<view class="bar-btn" :class="{ disabled: submitting }" @tap="submit">{{ submitting ? '提交中...' : '提交并支付' }}</view>
<view class="bar-l"><text class="bar-k">{{ priceType === 'quote' ? '参考价' : '合计' }}</text><text class="bar-p">¥{{ amount ? amount.pay_amount : 0 }}</text></view>
<view class="bar-btn" :class="{ disabled: submitting }" @tap="submit">
{{ submitting ? '提交中...' : (priceType === 'quote' ? '发布需求' : '提交并支付') }}
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, watch } from 'vue';
import { ref, reactive, watch, computed } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import api from '@/utils/api.js';
import { isLogin, toLogin } from '@/utils/request.js';
@@ -88,6 +135,21 @@
const urgent = ref(false);
const submitting = ref(false);
/** 三种下单方式:一口价 / 师傅报价竞价 / 用户自主定价 */
const modes = [
{ key: 'fixed', name: '一口价', desc: '平台明码标价' },
{ key: 'quote', name: '师傅报价', desc: '师傅竞价,您来选' },
{ key: 'custom', name: '自主定价', desc: '您出价,师傅接单' }
];
const priceType = ref('fixed');
const userPrice = ref('');
const modeNote = computed(() => {
if (priceType.value === 'quote') return '发布后进入报价大厅,师傅提交报价,您对比价格与评分后选择';
if (priceType.value === 'custom') return '填写您愿意支付的总价(含楼层等),师傅认可即可接单';
return '按平台标准价结算,支付后师傅先到先得';
});
const now = new Date();
const pad = (n) => (n < 10 ? '0' + n : '' + n);
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
@@ -100,19 +162,43 @@
});
let serviceId = 0;
let calcTimer = null;
async function calc() {
if (!serviceId) return;
const custom = priceType.value === 'custom';
if (custom && (!userPrice.value || Number(userPrice.value) <= 0)) {
amount.value = null;
return;
}
const floor = address.value ? address.value.floor : form.floor;
const elev = address.value ? address.value.has_elevator : form.has_elevator;
try {
amount.value = await api.calc({
service_id: serviceId, floor: floor, has_elevator: elev, urgent: urgent.value ? 1 : 0
service_id: serviceId,
floor: floor,
has_elevator: elev,
urgent: urgent.value ? 1 : 0,
price_type: priceType.value,
user_price: custom ? Number(userPrice.value) : 0
});
} catch (e) {}
}
watch([urgent, () => form.floor, () => form.has_elevator], () => calc());
function scheduleCalc() {
clearTimeout(calcTimer);
calcTimer = setTimeout(calc, 300);
}
function selectMode(key) {
if (priceType.value === key) return;
priceType.value = key;
amount.value = null;
calc();
}
watch([urgent, () => form.floor, () => form.has_elevator, priceType], scheduleCalc);
watch(userPrice, scheduleCalc);
function onDate(e) { date.value = e.detail.value; }
function onTime(e) { time.value = e.detail.value; }
@@ -168,6 +254,10 @@
if (submitting.value) return;
if (!isLogin()) { toLogin(); return; }
if (!address.value) { uni.showToast({ title: '请先保存服务地址', icon: 'none' }); return; }
if (priceType.value === 'custom' && (!userPrice.value || Number(userPrice.value) <= 0)) {
uni.showToast({ title: '请填写您的出价', icon: 'none' });
return;
}
submitting.value = true;
try {
const appoint = Math.floor(new Date(date.value.replace(/-/g, '/') + ' ' + time.value + ':00').getTime() / 1000);
@@ -175,13 +265,16 @@
service_id: serviceId,
address_id: address.value.id,
appoint_time: appoint,
urgent: urgent.value ? 1 : 0,
urgent: priceType.value === 'custom' ? 0 : (urgent.value ? 1 : 0),
goods_model: form.goods_model,
goods_num: form.goods_num,
remark: form.remark
remark: form.remark,
price_type: priceType.value,
user_price: priceType.value === 'custom' ? Number(userPrice.value) : 0
});
await api.orderPay({ order_id: res.order_id, pay_type: 'mock' });
uni.showToast({ title: '支付成功,等待师傅接单' });
const tip = priceType.value === 'quote' ? '支付成功,等待师傅报价' : '支付成功,等待师傅接单';
uni.showToast({ title: tip });
setTimeout(() => {
uni.redirectTo({ url: '/pages/order/detail?id=' + res.order_id });
}, 600);
@@ -196,6 +289,9 @@
serviceId = Number(options.service_id || 0);
const res = await api.serviceDetail(serviceId);
svc.value = res.detail;
// 默认沿用服务价态:报价服务 → 师傅报价;区间服务 → 自主定价;其余 → 一口价
priceType.value = svc.value.price_type === 'quote' ? 'quote'
: (svc.value.price_type === 'range' ? 'custom' : 'fixed');
await loadAddress();
calc();
});
@@ -223,6 +319,26 @@
.urgent.on .urgent-txt { color: #E36B0C; font-weight: 600; }
.sec { margin: 22rpx 28rpx 0; padding: 22rpx 24rpx; background: #fff; border-radius: 20rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,0.06); }
.sec-title { font-size: 28rpx; font-weight: 700; color: #1F1F1F; margin-bottom: 10rpx; }
.modes { display: flex; margin-top: 8rpx; }
.mode {
position: relative; flex: 1; min-width: 0; margin-right: 14rpx; padding: 20rpx 16rpx;
border-radius: 16rpx; background: #F8F8F8; border: 2rpx solid transparent;
display: flex; flex-direction: column;
}
.mode:last-child { margin-right: 0; }
.mode.on { background: #FFF3E6; border-color: #F0710D; }
.mode-name { font-size: 27rpx; font-weight: 700; color: #444; }
.mode.on .mode-name { color: #E36B0C; }
.mode-desc { margin-top: 6rpx; font-size: 20rpx; color: #9A9A9A; line-height: 28rpx; }
.mode.on .mode-desc { color: #C07A3A; }
.mode-check {
position: absolute; right: 10rpx; top: 8rpx; width: 30rpx; height: 30rpx; line-height: 30rpx;
text-align: center; border-radius: 50%; background: #F0710D; color: #fff; font-size: 20rpx;
}
.mode-note {
margin-top: 14rpx; padding: 14rpx 16rpx; border-radius: 12rpx;
background: #FAFAFA; font-size: 22rpx; color: #8A8A8A; line-height: 32rpx;
}
.addr { display: flex; align-items: center; }
.addr-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.addr-c { font-size: 28rpx; font-weight: 600; color: #222; }
@@ -241,11 +357,15 @@
.seg-i { padding: 0 26rpx; line-height: 52rpx; margin-right: 12rpx; border-radius: 26rpx; background: #F5F5F5; font-size: 24rpx; color: #666; }
.seg-i.on { background: linear-gradient(135deg, #FFA93F 0%, #F0710D 100%); color: #fff; }
.save-btn { margin-top: 20rpx; height: 76rpx; display: flex; align-items: center; justify-content: center; border-radius: 38rpx; background: #FFF3E6; color: #E36B0C; font-size: 26rpx; font-weight: 600; }
.price-input-wrap { flex: 1; display: flex; align-items: center; }
.price-unit { font-size: 32rpx; font-weight: 700; color: #F0710D; margin-right: 8rpx; }
.price-input { flex: 1; }
.li { display: flex; justify-content: space-between; padding: 14rpx 0; border-bottom: 1rpx solid #F4F4F4; }
.li.total { border-bottom: none; }
.k { font-size: 26rpx; color: #666; }
.v { font-size: 26rpx; color: #333; }
.v.big { font-size: 34rpx; font-weight: 700; color: #F0710D; }
.fee-note { display: block; margin-top: 10rpx; font-size: 21rpx; color: #9A9A9A; line-height: 30rpx; }
.bar {
position: fixed; left: 0; right: 0; bottom: 0; height: 120rpx;
padding: 0 28rpx; padding-bottom: env(safe-area-inset-bottom);
@@ -263,4 +383,3 @@
}
.bar-btn.disabled { opacity: 0.7; }
</style>

查看文件

@@ -30,11 +30,37 @@
<view class="li" v-if="Number(order.urgent)"><text class="k">加急</text><text class="v">是(+20%)</text></view>
</view>
<view class="card" v-if="order.price_type === 'quote'">
<view class="card-head">
<text class="card-title">师傅报价</text>
<text class="card-refresh" @tap="load">刷新</text>
</view>
<view class="quote-tip" v-if="order.status === 2 && quotes.length">已有 {{ quotes.length }} 位师傅报价,选择后师傅将上门服务</view>
<view class="quote-empty" v-if="!quotes.length">暂无师傅报价,请稍候…</view>
<view class="quote-item" v-for="q in quotes" :key="q.id" :class="{ chosen: q.status === 'accept' }">
<view class="q-top">
<view class="q-master">
<text class="q-name">{{ q.realname }}</text>
<text class="q-meta">评分 {{ q.score }} · 完成 {{ q.order_count }} 单</text>
</view>
<text class="q-amt">¥{{ q.amount }}</text>
</view>
<text class="q-desc" v-if="q.description">{{ q.description }}</text>
<view class="q-foot">
<text class="q-expire">有效期至 {{ q.expire_text }}</text>
<text class="q-status" :class="'qs-' + q.status">{{ q.status_text }}</text>
<view class="q-choose" v-if="canChoose && q.status === 'wait'" @tap="chooseQuote(q)">选择该师傅</view>
</view>
</view>
</view>
<view class="card">
<view class="card-title">费用明细</view>
<view class="li"><text class="k">基础价</text><text class="v">¥{{ order.base_amount }}</text></view>
<view class="li"><text class="k">楼层费</text><text class="v">¥{{ order.floor_amount }}</text></view>
<view class="li"><text class="k">计价方式</text><text class="v">{{ order.price_type_text }}</text></view>
<view class="li"><text class="k">{{ order.price_type === 'custom' ? '您的出价' : (order.price_type === 'quote' && Number(order.master_id) > 0 ? '师傅报价' : '基础价') }}</text><text class="v">¥{{ order.base_amount }}</text></view>
<view class="li" v-if="Number(order.floor_amount) > 0"><text class="k">楼层费</text><text class="v">¥{{ order.floor_amount }}</text></view>
<view class="li" v-if="Number(order.urgent_amount) > 0"><text class="k">加急费</text><text class="v">¥{{ order.urgent_amount }}</text></view>
<view class="li" v-if="Number(order.discount_amount) > 0"><text class="k">优惠券抵扣</text><text class="v">-¥{{ order.discount_amount }}</text></view>
<view class="li"><text class="k">实付</text><text class="v big">¥{{ order.pay_amount }}</text></view>
</view>
@@ -78,6 +104,7 @@
const master = ref(null);
const logs = ref([]);
const media = ref([]);
const quotes = ref([]);
let orderId = 0;
const statusTip = computed(() => {
@@ -85,12 +112,34 @@
return order.value.status_tip || '';
});
const canChoose = computed(() => {
return !!(order.value && order.value.price_type === 'quote'
&& Number(order.value.status) === 2 && Number(order.value.master_id) === 0
&& quotes.value.some(q => q.status === 'wait'));
});
async function load() {
const res = await api.orderDetail(orderId);
order.value = res.order;
master.value = res.master;
logs.value = res.logs || [];
media.value = res.media || [];
quotes.value = res.quotes || [];
}
function chooseQuote(q) {
uni.showModal({
title: '选择服务师傅',
content: `确认选择 ${q.realname} 的报价 ¥${q.amount}?选定后师傅将与您联系`,
success: async (r) => {
if (!r.confirm) return;
try {
await api.orderChooseQuote({ order_id: orderId, quote_id: q.id });
toast('已选定师傅');
load();
} catch (e) {}
}
});
}
function toast(t) { uni.showToast({ title: t, icon: 'none' }); }
@@ -129,6 +178,26 @@
.hero-tip { display: block; margin-top: 10rpx; font-size: 24rpx; color: rgba(255,255,255,0.9); }
.card { margin: 22rpx 28rpx 0; padding: 24rpx; background: #fff; border-radius: 20rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,0.06); }
.card-title { font-size: 28rpx; font-weight: 700; color: #1F1F1F; margin-bottom: 12rpx; }
.card-head { display: flex; align-items: center; justify-content: space-between; }
.card-refresh { font-size: 22rpx; color: #F0710D; }
.quote-tip { margin-bottom: 10rpx; padding: 12rpx 16rpx; border-radius: 12rpx; background: #FFF3E6; font-size: 22rpx; color: #C07A3A; line-height: 30rpx; }
.quote-empty { padding: 30rpx 0; text-align: center; font-size: 24rpx; color: #9A9A9A; }
.quote-item { padding: 18rpx 0; border-bottom: 1rpx solid #F4F4F4; }
.quote-item:last-child { border-bottom: none; }
.quote-item.chosen { background: #FFFBF6; border-radius: 12rpx; padding: 18rpx 14rpx; }
.q-top { display: flex; align-items: flex-start; justify-content: space-between; }
.q-master { display: flex; flex-direction: column; }
.q-name { font-size: 28rpx; font-weight: 700; color: #222; }
.q-meta { margin-top: 4rpx; font-size: 21rpx; color: #9A9A9A; }
.q-amt { font-size: 34rpx; font-weight: 700; color: #F0710D; }
.q-desc { display: block; margin-top: 10rpx; font-size: 23rpx; color: #666; line-height: 32rpx; }
.q-foot { display: flex; align-items: center; margin-top: 10rpx; }
.q-expire { flex: 1; font-size: 20rpx; color: #B5B5B5; }
.q-status { font-size: 21rpx; color: #9A9A9A; margin-right: 14rpx; }
.q-status.qs-wait { color: #4A7CFF; }
.q-status.qs-accept { color: #2FA84F; }
.q-status.qs-reject, .q-status.qs-expired { color: #B5B5B5; }
.q-choose { padding: 0 24rpx; line-height: 56rpx; border-radius: 28rpx; color: #fff; font-size: 23rpx; font-weight: 600; background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); }
.li { display: flex; justify-content: space-between; padding: 12rpx 0; border-bottom: 1rpx solid #F4F4F4; }
.k { flex-shrink: 0; width: 150rpx; font-size: 26rpx; color: #666; }
.v { flex: 1; text-align: right; font-size: 26rpx; color: #333; line-height: 36rpx; }

查看文件

@@ -40,7 +40,7 @@ class Order extends Model
public function getPriceTypeList()
{
return ['fixed' => __('Fixed'), 'quote' => __('Quote'), 'range' => __('Range')];
return ['fixed' => __('Fixed'), 'quote' => __('Quote'), 'range' => __('Range'), 'custom' => __('用户自主定价')];
}

查看文件

@@ -98,6 +98,7 @@ class Home extends Base
/**
* 费用试算(基础价 + 楼层费 + 加急费 - 优惠)
* 支持下单页实时切换:一口价 / 师傅报价 / 用户自主定价
*/
public function calc()
{
@@ -110,28 +111,48 @@ class Home extends Base
'floor' => (int)$this->request->param('floor', 1),
'has_elevator' => (int)$this->request->param('has_elevator', 1),
'urgent' => (int)$this->request->param('urgent', 0),
], (int)$this->request->param('coupon_id', 0), $this->uid());
], (int)$this->request->param('coupon_id', 0), $this->uid(),
(string)$this->request->param('price_type', ''),
(float)$this->request->param('user_price', 0));
$this->success('', $result);
}
/**
* 金额计算(下单与试算共用,保证前后端口径一致)
*
* @param array $service 服务行
* @param array $params floor / has_elevator / urgent
* @param int $couponId 用户优惠券 id
* @param int $uid 用户 id
* @param string $priceType 计价方式:fixed 一口价 / quote 师傅报价 / custom 用户自主定价
* @param float $userPrice 用户自主定价金额(总价,含楼层/加急)
*/
public static function calcAmount($service, $params, $couponId = 0, $uid = 0)
public static function calcAmount($service, $params, $couponId = 0, $uid = 0, $priceType = '', $userPrice = 0)
{
$allowTypes = ['fixed', 'quote', 'range', 'custom'];
$type = in_array($priceType, $allowTypes, true) ? $priceType : $service['price_type'];
$base = (float)$service['base_price'];
$floor = max(0, (int)$params['floor']);
$freeFloor = (int)$service['free_floor'];
$floorFee = (float)$service['floor_fee'];
$floorAmount = 0;
if ((int)$params['has_elevator'] === 0 && $floor > $freeFloor) {
$floorAmount = ($floor - $freeFloor) * $floorFee;
}
$urgentAmount = 0;
$urgentAmount = (int)$params['urgent'] === 1 ? round($base * 0.2, 2) : 0;
$total = round($base + $floorAmount + $urgentAmount, 2);
if ($type === 'custom') {
// 用户自主定价:出价即订单总价,已包含楼层费/加急费,师傅按此价接单
$base = max(0, round((float)$userPrice, 2));
$total = $base;
} else {
$floor = max(0, (int)$params['floor']);
$freeFloor = (int)$service['free_floor'];
$floorFee = (float)$service['floor_fee'];
if ((int)$params['has_elevator'] === 0 && $floor > $freeFloor) {
$floorAmount = ($floor - $freeFloor) * $floorFee;
}
$urgentAmount = (int)$params['urgent'] === 1 ? round($base * 0.2, 2) : 0;
$total = round($base + $floorAmount + $urgentAmount, 2);
}
$discount = 0;
$couponInfo = null;
@@ -151,7 +172,8 @@ class Home extends Base
}
return [
'price_type' => $service['price_type'],
'price_type' => $type,
'user_price' => $type === 'custom' ? round($base, 2) : 0,
'base_amount' => round($base, 2),
'floor_amount' => round($floorAmount, 2),
'urgent_amount' => round($urgentAmount, 2),
@@ -159,8 +181,8 @@ class Home extends Base
'discount_amount' => round($discount, 2),
'pay_amount' => max(0, round($total - $discount, 2)),
'coupon' => $couponInfo,
'free_floor' => $freeFloor,
'floor_fee' => round($floorFee, 2),
'free_floor' => $type === 'custom' ? 0 : (int)$service['free_floor'],
'floor_fee' => $type === 'custom' ? 0 : round((float)$service['floor_fee'], 2),
];
}

查看文件

@@ -144,7 +144,7 @@ class Master extends Base
}
/**
* 订单大厅:支持排序与单类型筛选(一口价 / 报价 / 悬赏)
* 订单大厅:支持排序与单类型筛选(一口价 / 师傅报价 / 用户自主定价)
*/
public function hall()
{
@@ -155,17 +155,20 @@ class Master extends Base
$page = max(1, (int)$this->request->param('page', 1));
$limit = min(30, max(1, (int)$this->request->param('limit', 10)));
$sort = $this->request->param('sort', 'new'); // new 最新 / amount 报酬 / urgent 急单 / time 时效
$type = $this->request->param('type', 'all'); // all / fixed 一口价 / quote 报价
$type = $this->request->param('type', 'all'); // all / fixed 一口价 / quote 师傅报价 / custom 用户自主定价
$build = function () use ($catIds, $type) {
$q = Db::name('order')->where('status', OrderStatus::PENDING_ACCEPT)->where('master_id', 0);
$q = Db::name('order')->where('master_id', 0)
->where('status', 'in', [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING]);
if (!empty($catIds)) {
$q->where('cat_id', 'in', $catIds);
}
if ($type === 'fixed') {
$q->where('price_type', 'fixed');
$q->where('status', OrderStatus::PENDING_ACCEPT)->where('price_type', 'fixed');
} elseif ($type === 'quote') {
$q->where('price_type', 'quote');
$q->where('status', 'in', [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING])->where('price_type', 'quote');
} elseif ($type === 'custom') {
$q->where('status', OrderStatus::PENDING_ACCEPT)->where('price_type', 'custom');
}
return $q;
};
@@ -193,6 +196,10 @@ class Master extends Base
$row['status_text'] = OrderStatus::label($row['status']);
$row['left_seconds'] = max(0, (int)$row['createtime'] + 3600 - $now);
$row['quote_count'] = (int)Db::name('order_quote')->where('order_id', $row['id'])->count();
$row['price_type_text'] = $row['price_type'] === 'fixed' ? '一口价'
: ($row['price_type'] === 'quote' ? '师傅报价'
: ($row['price_type'] === 'custom' ? '用户自主定价' : '按面积计价'));
$row['user_price'] = $row['price_type'] === 'custom' ? (float)$row['base_amount'] : 0;
}
unset($row);
@@ -217,6 +224,9 @@ class Master extends Base
if (!$order) {
$this->error('订单不存在');
}
if ($order['price_type'] === 'quote') {
$this->error('该订单为师傅报价订单,请提交报价');
}
if ((int)$order['status'] !== OrderStatus::PENDING_ACCEPT || (int)$order['master_id'] !== 0) {
$this->error('手慢了,该订单已被接走');
}
@@ -433,9 +443,15 @@ class Master extends Base
if (!$order) {
$this->error('订单不存在');
}
if ((int)$order['status'] !== OrderStatus::PENDING_ACCEPT) {
if ($order['price_type'] !== 'quote') {
$this->error('该订单不是报价竞价订单');
}
if (!in_array((int)$order['status'], [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING], true)) {
$this->error('该订单已不在报价阶段');
}
if ((int)$order['master_id'] > 0) {
$this->error('该订单已选定师傅');
}
if ($amount <= 0) {
$this->error('请填写报价金额');
}
@@ -453,6 +469,13 @@ class Master extends Base
'status' => 'wait',
'createtime' => time(),
]);
// 兼容旧流程:已支付但仍在待接单的报价单,收到首个报价时进入「报价中」
if ((int)$order['status'] === OrderStatus::PENDING_ACCEPT) {
$this->transit($order, OrderStatus::QUOTING, '收到师傅报价,订单进入报价中', [], 'master', (int)$master['id']);
}
$this->notify('user', (int)$order['user_id'], '收到新报价', '师傅 ' . $master['realname'] . ' 对订单 ' . $order['order_no'] . ' 报价 ¥' . round($amount, 2) . ',请前往订单详情选择', 'order', $orderId);
$this->success('报价已提交');
}
@@ -518,13 +541,17 @@ class Master extends Base
->limit(50)
->select();
foreach ($rows as $row) {
$toStatus = $row['price_type'] === 'quote' ? OrderStatus::QUOTING : OrderStatus::PENDING_ACCEPT;
Db::name('order')->where('id', $row['id'])->update([
'master_id' => 0,
'status' => OrderStatus::PENDING_ACCEPT,
'status' => $toStatus,
'accept_time' => null,
'updatetime' => time(),
]);
$this->addLog($row['id'], OrderStatus::PENDING_ACCEPT, '超 2 小时未联系客户,订单自动回流大厅', 'system', 0);
if ($row['price_type'] === 'quote') {
Db::name('order_quote')->where('order_id', $row['id'])->where('status', 'accept')->update(['status' => 'wait']);
}
$this->addLog($row['id'], $toStatus, $row['price_type'] === 'quote' ? '师傅超 2 小时未联系客户,订单重新开放报价' : '超 2 小时未联系客户,订单自动回流大厅', 'system', 0);
}
}

查看文件

@@ -39,6 +39,20 @@ class Order extends Base
$this->error('服务不存在或已下架');
}
// 下单页可选择:一口价 / 师傅报价 / 用户自主定价(未传则沿用服务默认)
$priceType = trim((string)$this->request->post('price_type', $service['price_type']));
if (!in_array($priceType, ['fixed', 'quote', 'custom', 'range'], true)) {
$priceType = $service['price_type'];
}
$userPrice = round((float)$this->request->post('user_price', 0), 2);
if ($priceType === 'custom') {
if ($userPrice < 1 || $userPrice > 100000) {
$this->error('请填写 1-100000 元的出价');
}
} else {
$userPrice = 0;
}
$address = Db::name('address')->where('id', $addressId)->where('user_id', $this->uid())->find();
if (!$address) {
$this->error('请选择服务地址');
@@ -52,7 +66,8 @@ class Order extends Base
'floor' => (int)$address['floor'],
'has_elevator' => (int)$address['has_elevator'],
'urgent' => $urgent,
], $couponId, $this->uid());
], $couponId, $this->uid(), $priceType, $userPrice);
$amount['price_type'] = $priceType;
$orderNo = $this->makeOrderNo();
$verifyCode = (string)mt_rand(1000, 9999);
@@ -65,7 +80,7 @@ class Order extends Base
'service_id' => (int)$service['id'],
'service_name' => $service['name'],
'service_image' => $service['image'],
'price_type' => $service['price_type'],
'price_type' => $priceType,
'goods_images' => is_array($goodsImages) ? json_encode($goodsImages, JSON_UNESCAPED_UNICODE) : (string)$goodsImages,
'goods_model' => mb_substr($goodsModel, 0, 100),
'goods_num' => $goodsNum,
@@ -96,7 +111,13 @@ class Order extends Base
'updatetime' => time(),
]);
$this->addLog($id, OrderStatus::PENDING_PAY, '用户下单', 'user', $this->uid());
$modeText = [
'fixed' => '一口价',
'quote' => '师傅报价',
'custom' => '用户自主定价',
'range' => '按面积计价',
];
$this->addLog($id, OrderStatus::PENDING_PAY, '用户下单(' . (isset($modeText[$priceType]) ? $modeText[$priceType] : '一口价') . ')', 'user', $this->uid());
$this->success('下单成功', [
'order_id' => (int)$id,
@@ -131,8 +152,12 @@ class Order extends Base
'transaction_id' => 'MOCK' . date('YmdHis') . mt_rand(1000, 9999),
'updatetime' => time(),
]);
$this->transit($order, OrderStatus::PENDING_ACCEPT, '支付成功,订单已进入接单大厅', [], 'user', $this->uid());
$this->addLog($orderId, OrderStatus::PENDING_ACCEPT, '订单进入师傅接单大厅', 'system', 0);
$nextStatus = $order['price_type'] === 'quote' ? OrderStatus::QUOTING : OrderStatus::PENDING_ACCEPT;
$nextRemark = $nextStatus === OrderStatus::QUOTING
? '支付成功,订单已进入报价大厅,等待师傅报价'
: '支付成功,订单已进入接单大厅';
$this->transit($order, $nextStatus, $nextRemark, [], 'user', $this->uid());
$this->addLog($orderId, $nextStatus, $nextStatus === OrderStatus::QUOTING ? '订单进入师傅报价大厅' : '订单进入师傅接单大厅', 'system', 0);
// 核销优惠券
if ((int)$order['coupon_id'] > 0) {
@@ -217,11 +242,161 @@ class Order extends Base
$media = Db::name('order_media')->where('order_id', $orderId)->order('id asc')->select();
// 报价订单:返回师傅报价列表,供用户比价并选择师傅
$quotes = [];
if ($order['price_type'] === 'quote') {
$rows = Db::name('order_quote')->alias('q')
->join('master m', 'm.id = q.master_id', 'LEFT')
->field('q.id,q.order_id,q.master_id,q.amount,q.description,q.valid_hours,q.expire_time,q.status,q.createtime,m.realname,m.mobile,m.avatar,m.score,m.order_count,m.level')
->where('q.order_id', $orderId)
->order('q.amount asc,q.id asc')
->select();
$statusText = ['wait' => '等待选择', 'accept' => '已选中', 'reject' => '未选中', 'expired' => '已过期'];
$now = time();
foreach ($rows as $q) {
if ($q['status'] === 'wait' && (int)$q['expire_time'] > 0 && (int)$q['expire_time'] < $now) {
Db::name('order_quote')->where('id', (int)$q['id'])->update(['status' => 'expired']);
$q['status'] = 'expired';
}
$quotes[] = [
'id' => (int)$q['id'],
'master_id' => (int)$q['master_id'],
'realname' => $q['realname'] ?: '师傅',
'avatar' => $q['avatar'],
'score' => (float)$q['score'],
'order_count' => (int)$q['order_count'],
'level' => (int)$q['level'],
'amount' => (float)$q['amount'],
'description' => $q['description'],
'valid_hours' => (int)$q['valid_hours'],
'status' => $q['status'],
'status_text' => isset($statusText[$q['status']]) ? $statusText[$q['status']] : $q['status'],
'time_text' => date('m-d H:i', (int)$q['createtime']),
'expire_text' => (int)$q['expire_time'] > 0 ? date('m-d H:i', (int)$q['expire_time']) : '长期有效',
];
}
}
$this->success('', [
'order' => $this->formatOrder($order),
'master' => $master,
'logs' => $logs,
'media' => $media,
'quotes' => $quotes,
'can_choose_quote' => $order['price_type'] === 'quote'
&& (int)$order['status'] === OrderStatus::QUOTING
&& (int)$order['master_id'] === 0,
]);
}
/**
* 报价订单:用户选择师傅报价(选定后订单进入已接单)
* @ApiMethod (POST)
*/
public function chooseQuote()
{
$orderId = (int)$this->request->post('order_id');
$quoteId = (int)$this->request->post('quote_id');
$order = $this->ownOrder($orderId, 'user');
if ($order['price_type'] !== 'quote') {
$this->error('该订单不是师傅报价订单');
}
if ((int)$order['status'] !== OrderStatus::QUOTING) {
$this->error('当前状态(' . OrderStatus::label((int)$order['status']) . ')不可选择报价');
}
if ((int)$order['master_id'] > 0) {
$this->error('已选定师傅,请勿重复选择');
}
$quote = Db::name('order_quote')->where('id', $quoteId)->where('order_id', $orderId)->find();
if (!$quote) {
$this->error('报价不存在');
}
if ($quote['status'] !== 'wait') {
$this->error('该报价已失效,请选择其他师傅');
}
if ((int)$quote['expire_time'] > 0 && (int)$quote['expire_time'] < time()) {
Db::name('order_quote')->where('id', $quoteId)->update(['status' => 'expired']);
$this->error('该报价已过期,请选择其他师傅');
}
$master = Db::name('master')->where('id', (int)$quote['master_id'])->find();
if (!$master) {
$this->error('师傅信息不存在');
}
$quoteAmount = round((float)$quote['amount'], 2);
$payAmount = max(0, round($quoteAmount - (float)$order['discount_amount'], 2));
$remark = '客户已选择 ' . $master['realname'] . ' 的报价 ¥' . $quoteAmount . ',等待师傅联系';
Db::startTrans();
try {
$this->transit($order, OrderStatus::ACCEPTED, $remark, [
'master_id' => (int)$quote['master_id'],
'accept_time' => time(),
'base_amount' => $quoteAmount,
'floor_amount' => 0,
'urgent_amount' => 0,
'total_amount' => $quoteAmount,
'pay_amount' => $payAmount,
], 'user', $this->uid());
Db::name('order_quote')->where('id', $quoteId)->update(['status' => 'accept']);
Db::name('order_quote')->where('order_id', $orderId)->where('id', '<>', $quoteId)
->where('status', 'wait')->update(['status' => 'reject']);
$this->notify('user', (int)$order['user_id'], '已选定服务师傅', $remark, 'order', $orderId);
$this->notify('master', (int)$master['user_id'], '您的报价已被选中', '订单 ' . $order['order_no'] . ' 客户已选择您的报价 ¥' . $quoteAmount . ',请尽快联系客户', 'order', $orderId);
Db::commit();
} catch (\Exception $e) {
Db::rollback();
$this->error('选择报价失败:' . $e->getMessage());
}
$this->success('已选定师傅,请等待师傅联系');
}
/**
* 报价订单:报价列表(详情页轮询/刷新使用)
*/
public function quotes()
{
$orderId = (int)$this->request->param('order_id');
$order = $this->ownOrder($orderId, 'user');
if ($order['price_type'] !== 'quote') {
$this->success('', ['list' => [], 'can_choose' => false]);
}
$rows = Db::name('order_quote')->alias('q')
->join('master m', 'm.id = q.master_id', 'LEFT')
->field('q.id,q.master_id,q.amount,q.description,q.valid_hours,q.expire_time,q.status,q.createtime,m.realname,m.avatar,m.score,m.order_count,m.level')
->where('q.order_id', $orderId)
->order('q.amount asc,q.id asc')
->select();
$statusText = ['wait' => '等待选择', 'accept' => '已选中', 'reject' => '未选中', 'expired' => '已过期'];
$list = [];
foreach ($rows as $q) {
$list[] = [
'id' => (int)$q['id'],
'master_id' => (int)$q['master_id'],
'realname' => $q['realname'] ?: '师傅',
'avatar' => $q['avatar'],
'score' => (float)$q['score'],
'order_count' => (int)$q['order_count'],
'amount' => (float)$q['amount'],
'description' => $q['description'],
'status' => $q['status'],
'status_text' => isset($statusText[$q['status']]) ? $statusText[$q['status']] : $q['status'],
'time_text' => date('m-d H:i', (int)$q['createtime']),
'expire_text' => (int)$q['expire_time'] > 0 ? date('m-d H:i', (int)$q['expire_time']) : '长期有效',
];
}
$this->success('', [
'list' => $list,
'can_choose' => (int)$order['status'] === OrderStatus::QUOTING && (int)$order['master_id'] === 0,
]);
}
@@ -355,7 +530,22 @@ class Order extends Base
'appoint_text' => $appoint ? date('m-d H:i', $appoint) : '尽快上门',
'create_text' => date('Y-m-d H:i', (int)$row['createtime']),
'urgent' => (int)$row['urgent'],
'price_type_text' => $this->priceTypeText($row['price_type']),
]);
}
/**
* 计价方式文案(前后端与后台统一口径)
*/
protected function priceTypeText($type)
{
$map = [
'fixed' => '一口价',
'quote' => '师傅报价',
'custom' => '用户自主定价',
'range' => '按面积计价',
];
return isset($map[$type]) ? $map[$type] : '一口价';
}
}

查看文件

@@ -37,7 +37,7 @@ class OrderStatus
/** 合法流转:from => [to, to, ...] */
protected static $transitions = [
self::PENDING_PAY => [self::PENDING_ACCEPT, self::CANCELLED],
self::PENDING_PAY => [self::PENDING_ACCEPT, self::QUOTING, self::CANCELLED],
self::PENDING_ACCEPT => [self::QUOTING, self::ACCEPTED, self::CANCELLED],
self::QUOTING => [self::ACCEPTED, self::CANCELLED],
self::ACCEPTED => [self::SCHEDULED, self::ARRIVED, self::PENDING_ACCEPT, self::CANCELLED],
@@ -140,6 +140,6 @@ class OrderStatus
*/
public static function canCancelByUser($status)
{
return in_array((int)$status, [self::PENDING_PAY, self::PENDING_ACCEPT, self::ACCEPTED, self::SCHEDULED], true);
return in_array((int)$status, [self::PENDING_PAY, self::PENDING_ACCEPT, self::QUOTING, self::ACCEPTED, self::SCHEDULED], true);
}
}

查看文件

@@ -31,6 +31,8 @@ export default {
orderVerify: (data) => post('/order/verify', data),
orderList: (data) => get('/order/index', data),
orderDetail: (id) => get('/order/detail', { id: id }),
orderQuotes: (orderId) => get('/order/quotes', { order_id: orderId }),
orderChooseQuote: (data) => post('/order/chooseQuote', data),
/* ---------- 师傅端 ---------- */
masterApply: (data) => post('/master/apply', data),