售后金额上浮

This commit is contained in:
liu
2026-09-07 23:27:56 +08:00
parent 58c733baa2
commit edf5e42c38
6 changed files with 130 additions and 25 deletions
File diff suppressed because one or more lines are too long
@@ -623,8 +623,9 @@ class PurchaseOrderController extends BaseController
->get(['id', 'store_id', 'total_amount']);
$stores = StoreModel::withTrashed()
->with('level:id,name,percent')
->whereIn('id', $orders->pluck('store_id')->unique())
->get(['id', 'name'])
->get(['id', 'name', 'level_id'])
->keyBy('id');
$bills = BillModel::query()
@@ -642,9 +643,13 @@ class PurchaseOrderController extends BaseController
'0'
);
$bill = $bills->get((int) $storeId);
$store = $stores->get((int) $storeId);
$rows[] = [
'store_id' => (int) $storeId,
'store_name' => $stores->get((int) $storeId)->name ?? ('门店#' . $storeId),
'store_name' => $store->name ?? ('门店#' . $storeId),
// 客户等级(售后金额上浮折算预览用;无等级=不上浮)
'level_name' => $store?->level?->name,
'level_percent' => (string) ($store?->level?->percent ?? '0'),
'order_count' => $storeOrders->count(),
'product_amount' => $productAmount,
'box_price' => $boxPrice,
+16 -2
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\ContainerReturnModel;
use App\Models\CustomerLevelModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
@@ -20,7 +21,8 @@ use Throwable;
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照;
* 数量正数=压筐附加金额,负数=回筐抵扣金额)
* 4. 售后金额 = 按门店填写的调整金额(可正负:正数=加收,负数=售后减免)
* 4. 售后金额 = 按门店填写的调整金额 × 门店客户等级上浮比例((100+percent)/100,与售价同口径;
* 可正负:正数=加收,负数=售后减免)
* 5. 总金额 = 商品金额 + 配送费 + 附加金额 + 售后金额;回写门店订单 bill_id 完成关联,订单状态置为已完成
* 6. 压回筐记录:筐/托盘数量非 0 时写入完整快照(数量/单价/金额均可为负)
*/
@@ -82,6 +84,14 @@ readonly class BillGenerateService
$boxPrice = (string) site_config('services.box_amount', 0);
$trayPrice = (string) site_config('services.tray_amount', 0);
$billDate = now()->toDateString();
// 各门店客户等级上浮比例(售后金额折算用;无等级按 0 不上浮)
$levelPercents = StoreModel::withTrashed()
->with('level:id,percent')
->whereIn('id', $ordersByStore->keys())
->get(['id', 'level_id'])
->mapWithKeys(static fn (StoreModel $store) => [
$store->id => (string) ($store->level?->percent ?? '0'),
]);
$bills = [];
foreach ($ordersByStore as $storeId => $storeOrders) {
$row = $submitted[(int) $storeId];
@@ -96,7 +106,11 @@ readonly class BillGenerateService
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
2
);
$afterSale = bcadd((string) ($row['after_sale'] ?? '0'), '0', 2);
// 售后金额按客户等级上浮比例折算(输入 15、上浮 1% → 实收 15.15;负数同比例放大)
$afterSale = CustomerLevelModel::calcLevelPrice(
(string) ($row['after_sale'] ?? '0'),
$levelPercents[(int) $storeId] ?? '0',
);
$totalAmount = bcadd(bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2), $afterSale, 2);
$bill = BillModel::create([
+50
View File
@@ -133,6 +133,56 @@ class BillPaymentTest extends ProcurementTestCase
$this->assertSame('5.00', (string) $bill2->total_amount, '未传售后金额不影响总金额');
}
/** 售后金额按客户等级上浮折算:输入 15、上浮 1% → 实收 15.15billPrepare 回显等级(负数同比例放大) */
public function test_generate_bill_after_sale_marked_up_by_level(): void
{
$level = CustomerLevelModel::factory()->create(['percent' => 1]);
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$level2 = CustomerLevelModel::factory()->create(['percent' => 2]);
$store2 = StoreModel::factory()->create(['level_id' => $level2->id]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']);
// 两店各下一单:店1 等级价 5.05 × 4 = 20.20;店2 等级价 5.10 × 2 = 10.20
foreach ([[$store, 4], [$store2, 2]] as [$s, $qty]) {
$this->actingAsMiniStore($s);
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
->assertJsonPath('success', true);
}
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$purchase = PurchaseOrderModel::first();
$this->putJson("/purchase/order/{$purchase->id}/finish")->assertJsonPath('success', true);
// billPrepare 回显客户等级与上浮比例(弹窗预览用)
$prepare = $this->getJson("/purchase/order/{$purchase->id}/bill/prepare")
->assertJsonPath('success', true)
->json('data.stores');
$prepareByStore = collect($prepare)->keyBy('store_id');
$this->assertSame($level->name, $prepareByStore[$store->id]['level_name']);
$this->assertSame('1.00', (string) $prepareByStore[$store->id]['level_percent']);
$this->assertSame($level2->name, $prepareByStore[$store2->id]['level_name']);
$this->assertSame('2.00', (string) $prepareByStore[$store2->id]['level_percent']);
// 店1 售后 15 × 101% = 15.15;店2 售后 -10 × 102% = -10.20
$this->postJson("/purchase/order/{$purchase->id}/bill", [
'stores' => [
['store_id' => $store->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0, 'after_sale' => 15],
['store_id' => $store2->id, 'delivery_fee' => 0, 'box_num' => 0, 'tray_num' => 0, 'after_sale' => -10],
],
])->assertJsonPath('success', true);
$bill = BillModel::where('store_id', $store->id)->first();
$this->assertSame('15.15', (string) $bill->after_sale, '15 × 101%');
$this->assertSame('35.35', (string) $bill->total_amount, '商品 20.20 + 售后 15.15');
$bill2 = BillModel::where('store_id', $store2->id)->first();
$this->assertSame('-10.20', (string) $bill2->after_sale, '-10 × 102%(负数同比例放大)');
$this->assertSame('0.00', (string) $bill2->total_amount, '商品 10.20 + 售后 -10.20');
}
/** 生成账单:售后金额为正数时加收计入总金额 */
public function test_generate_bill_positive_after_sale_added_to_total(): void
{
+4
View File
@@ -147,6 +147,10 @@ export const BILL_STATUS_MAP: Record<number, { text: string; color: string }> =
export interface IBillPrepareStore {
store_id: number;
store_name: string;
/** 客户等级名(无等级为 null) */
level_name?: string | null;
/** 客户等级上浮比例(售后金额折算用;无等级为 '0' */
level_percent?: string;
order_count: number;
/** 商品金额(订单汇总,不可修改) */
product_amount: string;
+45 -13
View File
@@ -86,6 +86,10 @@ const calcUnitRefPrice = (total: number, spec: string): number => {
return Number.isFinite(pack) && pack > 0 ? total / pack : total;
};
/** 售后金额按客户等级上浮折算:实收 = 输入 × (100+percent)/100(两位小数截断,与后端 calcLevelPrice 同口径) */
const calcLevelAmount = (amount: number, percent: number): number =>
Math.trunc(amount * (100 + percent)) / 100;
/**
* 采购单管理(C1 在门店订单页合并生成 / C2-C3 导出 / C4 明细矩阵修改)
* 采购单无独立明细表,明细直接溯源门店订货明细:商品行 × 门店列
@@ -209,17 +213,20 @@ const PurchaseOrderPage: React.FC = () => {
const [billSaving, setBillSaving] = useState(false);
const [billForm] = Form.useForm<{ stores: BillGenerateStoreParams[] }>();
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;总额 = 商品 + 配送费 + 附加 + 售后金额(可正负)
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;售后金额 = 输入 × 客户等级上浮比例;总额 = 商品 + 配送费 + 附加 + 售后(可正负)
const watchBillStores = Form.useWatch('stores', billForm) ?? [];
const billPreview = (billPrepare?.stores ?? []).map((row, index) => {
const input = watchBillStores[index] ?? {};
const deliveryFee = Number(input.delivery_fee ?? 0);
const afterSale = Number(input.after_sale ?? 0);
const levelPercent = Number(row.level_percent ?? 0);
const afterSaleInput = Number(input.after_sale ?? 0);
const afterSale = calcLevelAmount(afterSaleInput, levelPercent);
const added =
Number(input.box_num ?? 0) * Number(row.box_price) +
Number(input.tray_num ?? 0) * Number(row.tray_price);
return {
added,
afterSale,
total: Number(row.product_amount) + deliveryFee + added + afterSale,
};
});
@@ -1460,7 +1467,7 @@ const PurchaseOrderPage: React.FC = () => {
{billPrepare && (
<>
<div className="py-2 text-gray-500">
/====
/== 15 1% 15.15
{billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
</div>
<Form form={billForm} layout="vertical" onFinish={handleBillSave}>
@@ -1471,9 +1478,18 @@ const PurchaseOrderPage: React.FC = () => {
<div className="w-40 shrink-0"></div>
<div className="w-28 shrink-0 text-center"></div>
<div className="w-32 shrink-0 text-center"></div>
<div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.box_price ?? '0.00'}/</div>
<div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.tray_price ?? '0.00'}/</div>
<div className="w-36 shrink-0 text-center"></div>
<div className="w-32 shrink-0 text-center">
<div></div>
<div>¥{billPrepare.stores[0]?.box_price ?? '0.00'}/</div>
</div>
<div className="w-32 shrink-0 text-center">
<div></div>
<div>¥{billPrepare.stores[0]?.tray_price ?? '0.00'}/</div>
</div>
<div className="w-50 shrink-0 text-center">
<div></div>
<div></div>
</div>
<div className="w-36 shrink-0 text-center"></div>
<div className="w-28 shrink-0 text-center"></div>
<div className="flex-1 text-center"></div>
@@ -1489,6 +1505,11 @@ const PurchaseOrderPage: React.FC = () => {
{row?.order_count ?? 0}
{billed && <Tag className="ml-1!" color="success"></Tag>}
</div>
<div className="text-xs text-gray-400">
{row?.level_name
? `${row.level_name} +${Number(row.level_percent ?? 0)}%`
: '无等级(不上浮)'}
</div>
</div>
<div className="w-28 shrink-0 text-center">
<Text strong>¥{row?.product_amount ?? '0.00'}</Text>
@@ -1497,7 +1518,7 @@ const PurchaseOrderPage: React.FC = () => {
<Input />
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
className="m-0! w-32 shrink-0 px-1! flex justify-center"
name={[field.name, 'delivery_fee']}
rules={[{ required: true, message: '请输入配送费' }]}
>
@@ -1510,7 +1531,7 @@ const PurchaseOrderPage: React.FC = () => {
/>
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
className="m-0! w-32 shrink-0 px-1! flex justify-center"
name={[field.name, 'box_num']}
rules={[{ required: true, message: '请输入周转筐数量' }]}
>
@@ -1522,7 +1543,7 @@ const PurchaseOrderPage: React.FC = () => {
/>
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
className="m-0! w-32 shrink-0 px-1! flex justify-center"
name={[field.name, 'tray_num']}
rules={[{ required: true, message: '请输入周转托盘数量' }]}
>
@@ -1533,17 +1554,28 @@ const PurchaseOrderPage: React.FC = () => {
placeholder="正压负回"
/>
</Form.Item>
<Form.Item
className="m-0! w-36 shrink-0 px-1!"
name={[field.name, 'after_sale']}
>
<div className="w-50 shrink-0 px-1! flex justify-center">
<Space.Compact block>
<Space.Addon>
{/* 售后金额按客户等级上浮折算预览(实收 = 输入 × (100+上浮%)/100);已生成账单行为存档值不再折算 */}
{(
<div className="text-xs text-orange-500 text-center">
¥{billPreview[field.name]?.afterSale.toFixed(2)}
</div>
)}
</Space.Addon>
<Form.Item className="m-0!" name={[field.name, 'after_sale']}>
<InputNumber
className="w-full"
precision={2}
prefix={'¥'}
disabled={billed}
placeholder="可正负,计入总额"
/>
</Form.Item>
</Space.Compact>
</div>
<Form.Item
className="m-0! w-36 shrink-0 px-1!"
name={[field.name, 'remark']}