增加批量绑定

This commit is contained in:
wuweihua
2026-10-30 11:07:51 +08:00
parent 6b10a41b9d
commit 8ed97fed4a
11 changed files with 355 additions and 80 deletions
@@ -27,6 +27,7 @@ import com.bms.common.enums.BusinessType;
import com.bms.common.utils.MessageUtils;
import com.bms.common.utils.StringUtils;
import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcCustomerPriceSetBatch;
import com.bms.price.domain.PrcPriceExcelFile;
import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetDetail;
@@ -378,6 +379,16 @@ public class PriceSetController extends BaseController
return toAjax(priceSetService.insertCustomer(customer));
}
@PreAuthorize("@ss.hasPermi('price:set:edit')")
@Log(title = "套餐客户批量绑定", businessType = BusinessType.INSERT)
@PostMapping("/customer/batch")
public AjaxResult batchAddCustomer(@RequestBody PrcCustomerPriceSetBatch batch)
{
batch.setCreateBy(getUsername());
batch.setUpdateBy(getUsername());
return success(priceSetService.batchInsertCustomers(batch));
}
@PreAuthorize("@ss.hasPermi('price:set:edit')")
@Log(title = "套餐客户绑定", businessType = BusinessType.UPDATE)
@PutMapping("/customer")
@@ -230,3 +230,5 @@ price.bill.export.tooMany=匹配订单超过上限 {0} 条,请缩小筛选范
price.bill.export.noMatch=未查询到匹配订单,无法导出
price.bill.export.notReady=导出文件尚未生成或已失败
price.bill.export.fileMissing=导出文件不存在或路径非法
price.set.customer.batch.empty=请选择要绑定的客户
price.set.customer.batch.done=批量绑定完成:共 {0} 个客户,新绑定 {1} 个,更换 {2} 个,跳过 {3} 个
@@ -69,10 +69,6 @@ public class BmsCustomerPriceSetServiceImpl implements IBmsCustomerPriceSetServi
{
throw new ServiceException("所选套餐不存在");
}
if (!"E".equals(set.getStatus()))
{
throw new ServiceException("仅生效中的套餐允许绑定,当前套餐状态不可用");
}
if (bind.getBindPriority() == null || bind.getBindPriority() < 1)
{
bind.setBindPriority(1);
@@ -141,10 +137,6 @@ public class BmsCustomerPriceSetServiceImpl implements IBmsCustomerPriceSetServi
{
throw new ServiceException("所选套餐不存在");
}
if (!"E".equals(set.getStatus()))
{
throw new ServiceException("仅生效中的套餐允许绑定,当前套餐状态不可用");
}
Integer priority = (req.getBindPriority() == null || req.getBindPriority() < 1) ? 1 : req.getBindPriority();
boolean overwrite = Boolean.TRUE.equals(req.getOverwriteExisting());
@@ -0,0 +1,30 @@
package com.bms.price.domain;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.bms.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* Price-set detail batch bind customers.
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class PrcCustomerPriceSetBatch extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long priceSetId;
private Integer bindPriority;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date prBegindate;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date prEnddate;
private String priceSetNote;
/** Customers to bind (need customerCode; id/name optional). */
private List<PrcCustomerPriceSet> customers;
/** true = move already-bound customers onto this set; false = skip them. */
private Boolean overwriteExisting;
}
@@ -5,6 +5,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcCustomerPriceSetBatch;
import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetCheckItem;
import com.bms.price.domain.PrcPriceSetDetail;
@@ -92,6 +93,9 @@ public interface IPrcPriceSetService
public int insertCustomer(PrcCustomerPriceSet customer);
/** Batch bind customers to a price set; returns summary message. */
public String batchInsertCustomers(PrcCustomerPriceSetBatch batch);
public int updateCustomer(PrcCustomerPriceSet customer);
public int deleteCustomerByIds(Long[] customerPricesetIds);
@@ -2432,6 +2432,128 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
}
@Override
@org.springframework.transaction.annotation.Transactional(rollbackFor = Exception.class)
public String batchInsertCustomers(com.bms.price.domain.PrcCustomerPriceSetBatch batch)
{
if (batch == null || batch.getCustomers() == null || batch.getCustomers().isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.customer.batch.empty"));
}
if (batch.getPriceSetId() == null)
{
throw new ServiceException(MessageUtils.message("price.set.customer.customerId.empty"));
}
PrcPriceSet db = requireSet(batch.getPriceSetId());
assertChildEditable(db);
Integer priority = (batch.getBindPriority() == null || batch.getBindPriority().intValue() < 1)
? Integer.valueOf(1) : batch.getBindPriority();
boolean overwrite = Boolean.TRUE.equals(batch.getOverwriteExisting());
java.util.LinkedHashMap<String, PrcCustomerPriceSet> uniq = new java.util.LinkedHashMap<String, PrcCustomerPriceSet>();
for (PrcCustomerPriceSet item : batch.getCustomers())
{
if (item == null || StringUtils.isEmpty(item.getCustomerCode()))
{
continue;
}
String code = item.getCustomerCode().trim();
if (!uniq.containsKey(code))
{
uniq.put(code, item);
}
}
if (uniq.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.customer.batch.empty"));
}
int inserted = 0;
int replaced = 0;
int skipped = 0;
java.util.List<String> skippedNames = new java.util.ArrayList<String>();
for (PrcCustomerPriceSet item : uniq.values())
{
PrcCustomerPriceSet query = new PrcCustomerPriceSet();
query.setCustomerCode(item.getCustomerCode().trim());
java.util.List<PrcCustomerPriceSet> found = customerMapper.selectPrcCustomerPriceSetList(query);
PrcCustomerPriceSet existing = (found == null || found.isEmpty()) ? null : found.get(0);
PrcCustomerPriceSet row = new PrcCustomerPriceSet();
row.setPriceSetId(batch.getPriceSetId());
row.setCustomerId(item.getCustomerId());
row.setCustomerCode(item.getCustomerCode().trim());
row.setCustomerName(item.getCustomerName());
row.setBindPriority(priority);
row.setPrBegindate(batch.getPrBegindate());
row.setPrEnddate(batch.getPrEnddate());
row.setPriceSetNote(batch.getPriceSetNote());
row.setCreateBy(batch.getCreateBy());
row.setUpdateBy(batch.getUpdateBy());
if (existing != null)
{
boolean sameSet = batch.getPriceSetId().equals(existing.getPriceSetId());
if (sameSet)
{
skipped++;
skippedNames.add(StringUtils.isNotEmpty(item.getCustomerName())
? item.getCustomerName() : item.getCustomerCode());
continue;
}
if (!overwrite)
{
skipped++;
skippedNames.add(StringUtils.isNotEmpty(item.getCustomerName())
? item.getCustomerName() : item.getCustomerCode());
continue;
}
row.setCustomerPricesetId(existing.getCustomerPricesetId());
if (row.getPrBegindate() == null)
{
row.setPrBegindate(existing.getPrBegindate());
}
if (row.getPrEnddate() == null)
{
row.setPrEnddate(existing.getPrEnddate());
}
validateCustomer(row, db);
customerMapper.updatePrcCustomerPriceSet(row);
replaced++;
}
else
{
validateCustomer(row, db);
try
{
customerMapper.insertPrcCustomerPriceSet(row);
}
catch (Exception e)
{
if (isCustomerUniqueViolation(e))
{
skipped++;
skippedNames.add(StringUtils.isNotEmpty(item.getCustomerName())
? item.getCustomerName() : item.getCustomerCode());
continue;
}
throw e;
}
inserted++;
}
}
String msg = MessageUtils.message("price.set.customer.batch.done",
Integer.valueOf(uniq.size()), Integer.valueOf(inserted),
Integer.valueOf(replaced), Integer.valueOf(skipped));
if (skipped > 0 && !overwrite)
{
String names = skippedNames.stream().limit(10).collect(java.util.stream.Collectors.joining(", "));
msg = msg + " [" + names + (skippedNames.size() > 10 ? "..." : "") + "]";
}
return msg;
}
@Override
public int updateCustomer(PrcCustomerPriceSet customer)
{
@@ -2962,6 +3084,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
throw new ServiceException(MessageUtils.message("price.set.customer.customerId.empty"));
}
// UI no longer collects bind dates: fall back to set / version / today.
// Do not reject empty begin date — always fill a value.
if (customer.getPrBegindate() == null)
{
customer.setPrBegindate(set.getEffectiveDate());
@@ -2972,11 +3096,17 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
if (customer.getPrBegindate() == null)
{
throw new ServiceException(MessageUtils.message("price.set.customer.beginDate.empty"));
customer.setPrBegindate(DateUtils.getNowDate());
}
if (customer.getPrEnddate() != null && customer.getPrBegindate().after(customer.getPrEnddate()))
if (customer.getPrEnddate() == null)
{
throw new ServiceException(MessageUtils.message("price.set.customer.date.invalid"));
customer.setPrEnddate(set.getExpireDate());
}
// Auto-filled dates may conflict (e.g. set expire already past); keep bind, clear end.
if (customer.getPrEnddate() != null && customer.getPrBegindate() != null
&& customer.getPrBegindate().after(customer.getPrEnddate()))
{
customer.setPrEnddate(null);
}
if (customer.getBindPriority() != null && customer.getBindPriority().intValue() < 1)
{
@@ -141,6 +141,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<update id="updatePrcCustomerPriceSet" parameterType="PrcCustomerPriceSet">
update prc_customer_price_set
<set>
<if test="priceSetId != null">price_set_id = #{priceSetId},</if>
<if test="customerId != null">customer_id = #{customerId},</if>
<if test="customerCode != null">customer_code = #{customerCode},</if>
<if test="customerName != null">customer_name = #{customerName},</if>
+8
View File
@@ -407,6 +407,14 @@ export function addSetCustomer(data) {
})
}
export function batchAddSetCustomer(data) {
return request({
url: '/price/set/customer/batch',
method: 'post',
data: data
})
}
export function updateSetCustomer(data) {
return request({
url: '/price/set/customer',
@@ -7,22 +7,47 @@
class="ps-dialog"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="96px">
<el-form-item label="客户" prop="customerCode">
<el-form-item :label="isEdit ? '客户' : '客户'" :prop="isEdit ? 'customerCode' : 'customerCodes'">
<customer-select
v-if="isEdit"
v-model="form.customerCode"
value-key="customerCode"
placeholder="请选择客户"
@change="onCustomerSelectChange"
/>
<customer-select
v-else
v-model="form.customerCodes"
value-key="customerCode"
multiple
placeholder="请选择客户(可多选)"
@change="onBatchCustomerChange"
/>
</el-form-item>
<el-form-item label="客户信息">
<el-form-item v-if="isEdit" label="客户信息">
<div class="ps-readonly" :class="{ 'ps-readonly--empty': !form.customerCode }">
<span class="ps-readonly__code">{{ form.customerCode || '未选择' }}</span>
<span class="ps-readonly__name">{{ form.customerName || '选择客户后自动带出' }}</span>
</div>
</el-form-item>
<el-form-item v-else label="已选">
<div v-if="!selectedCustomers.length" class="ps-readonly ps-readonly--empty">
<span class="ps-readonly__name">请从上方下拉多选客户</span>
</div>
<div v-else class="batch-tags">
<el-tag
v-for="c in selectedCustomers"
:key="c.customerCode"
size="small"
closable
class="batch-tag"
@close="removeSelected(c.customerCode)"
>{{ c.customerName || c.customerCode }}</el-tag>
</div>
</el-form-item>
<el-form-item label="优先级" prop="bindPriority">
<el-input-number
v-model="form.bindPriority"
@@ -33,32 +58,11 @@
<div class="ps-hint">数字越小优先级越高,1 为最高;同一客户命中多个套餐时按此排序</div>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="生效日期" prop="prBegindate">
<el-date-picker
v-model="form.prBegindate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
<el-form-item v-if="!isEdit">
<el-checkbox v-model="form.overwriteExisting">
覆盖已绑定其他套餐的客户(不勾选则跳过)
</el-checkbox>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="失效日期" prop="prEnddate">
<el-date-picker
v-model="form.prEnddate"
type="date"
value-format="YYYY-MM-DD"
placeholder="长期有效"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<div class="ps-hint ps-hint--block">{{ bindRangeHint }}</div>
</el-form>
<template #footer>
@@ -70,25 +74,20 @@
<script setup name="SetCustomerDialog">
/**
* 套餐绑定客户 · 新增 / 编辑弹窗
* 由表格组件通过 ref.open(row) 唤起,保存成功后 emit('success') 通知页面刷新。
* 外观统一走 styles/dialog.scss 的 .ps-dialog(见该文件顶部说明)。
* 套餐绑定客户 · 新增(支持批量) / 编辑弹窗
*/
import { createEmptyCustomerRow } from '../../utils/priceSet'
import { addSetCustomer, updateSetCustomer } from '@/api/price/set'
import { batchAddSetCustomer, updateSetCustomer } from '@/api/price/set'
const props = defineProps({
/** 所属套餐ID */
priceSetId: {
type: [Number, String],
default: undefined
},
/** 新行默认生效日期(套餐生效日期) */
defaultBegindate: {
type: String,
default: undefined
},
/** 新行默认失效日期(套餐失效日期) */
defaultEnddate: {
type: String,
default: undefined
@@ -102,64 +101,112 @@ const { proxy } = getCurrentInstance()
const visible = ref(false)
const title = ref('')
const submitting = ref(false)
const isEdit = ref(false)
const formRef = ref()
const form = ref({})
const selectedCustomers = ref([])
const rules = {
customerCode: [{ required: true, message: '请选择客户', trigger: 'change' }],
prBegindate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
}
/** 有效期一句话说明,替掉原来两行零散提示 */
const bindRangeHint = computed(() => {
const begin = form.value.prBegindate
const end = form.value.prEnddate
if (!begin) return '生效日期为必填,失效日期留空表示长期有效。'
return end
? `该客户在 ${begin} 至 ${end} 期间的询价将命中本套餐;失效日期留空表示长期有效。`
: `该客户自 ${begin} 起长期命中本套餐;如需限制期限,请填写失效日期。`
const rules = computed(() => {
if (isEdit.value) {
return {
customerCode: [{ required: true, message: '请选择客户', trigger: 'change' }]
}
}
return {
customerCodes: [{
type: 'array',
required: true,
min: 1,
message: '请至少选择一个客户',
trigger: 'change'
}]
}
})
/** 打开弹窗:row 为空表示新增 */
function open(row) {
form.value = row
? { ...row }
: {
isEdit.value = !!row
selectedCustomers.value = []
if (row) {
form.value = { ...row }
title.value = '编辑客户绑定'
} else {
form.value = {
...createEmptyCustomerRow({
effectiveDate: props.defaultBegindate,
expireDate: props.defaultEnddate
}),
priceSetId: props.priceSetId
priceSetId: props.priceSetId,
customerCodes: [],
overwriteExisting: false
}
title.value = '添加客户'
}
title.value = row ? '编辑客户绑定' : '添加客户'
visible.value = true
nextTick(() => formRef.value && formRef.value.clearValidate())
}
/** 下拉选中客户后回填代码 / 名称 / ID */
function onCustomerSelectChange(code, item) {
form.value.customerCode = code || undefined
form.value.customerName = item ? item.customerName : ''
form.value.customerId = item ? item.customerId : undefined
}
function onBatchCustomerChange(codes, items) {
form.value.customerCodes = Array.isArray(codes) ? codes : []
selectedCustomers.value = Array.isArray(items) ? items.slice() : []
}
function removeSelected(code) {
form.value.customerCodes = (form.value.customerCodes || []).filter(c => String(c) !== String(code))
selectedCustomers.value = selectedCustomers.value.filter(c => String(c.customerCode) !== String(code))
}
async function submit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
const payload = { ...form.value, priceSetId: props.priceSetId }
submitting.value = true
const req = payload.customerPricesetId ? updateSetCustomer(payload) : addSetCustomer(payload)
req.then(() => {
try {
if (isEdit.value) {
const payload = { ...form.value, priceSetId: props.priceSetId }
await updateSetCustomer(payload)
proxy.$modal.msgSuccess('保存成功')
} else {
const customers = selectedCustomers.value.map(c => ({
customerId: c.customerId,
customerCode: c.customerCode,
customerName: c.customerName
}))
const res = await batchAddSetCustomer({
priceSetId: props.priceSetId,
bindPriority: form.value.bindPriority,
prBegindate: form.value.prBegindate,
prEnddate: form.value.prEnddate,
overwriteExisting: !!form.value.overwriteExisting,
customers
})
proxy.$modal.msgSuccess(res.msg || '批量绑定成功')
}
visible.value = false
emit('success')
}).finally(() => {
} finally {
submitting.value = false
})
}
}
defineExpose({ open })
</script>
<!-- 非 scoped:弹窗被 append-to-body 传送到 body,样式需全局生效,命名空间见 styles/dialog.scss -->
<style lang="scss" src="../../styles/dialog.scss"></style>
<style scoped>
.batch-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
width: 100%;
max-height: 140px;
overflow-y: auto;
}
.batch-tag {
max-width: 100%;
}
</style>
@@ -5,6 +5,17 @@
:count="customers.length"
sub="一个客户只能绑定一个套餐"
>
<template #action>
<el-button
v-if="editable"
type="primary"
size="small"
icon="Plus"
@click="dialogRef.open()"
v-hasPermi="['price:set:edit']"
>添加客户</el-button>
</template>
<el-table :data="customers" border class="customer-table" empty-text="暂未绑定客户">
<el-table-column label="客户代码" prop="customerCode" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
@@ -18,18 +29,57 @@
<span v-else class="priority">{{ row.bindPriority }}</span>
</template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="dialogRef.open(row)" v-hasPermi="['price:set:edit']">编辑</el-button>
<el-button link type="danger" @click="remove(row)" v-hasPermi="['price:set:edit']">解绑</el-button>
</template>
</el-table-column>
</el-table>
<set-customer-dialog
ref="dialogRef"
:price-set-id="set.priceSetId"
:default-begindate="set.effectiveDate"
:default-enddate="set.expireDate"
@success="emit('refresh')"
/>
</set-section-card>
</template>
<script setup name="SetCustomerTable">
/**
* 套餐详情 · 绑定客户表格
* 自带新增 / 编辑弹窗与解绑,数据变更后 emit('refresh') 由页面重新拉取详情。
*/
import SetSectionCard from './SetSectionCard.vue'
import SetCustomerDialog from './SetCustomerDialog.vue'
import { isChildEditable } from '../../utils/priceSet'
import { delSetCustomer } from '@/api/price/set'
const props = defineProps({
set: { type: Object, required: true }
/** 套餐详情对象 */
set: {
type: Object,
required: true
}
})
const emit = defineEmits(['refresh'])
const { proxy } = getCurrentInstance()
const dialogRef = ref()
const customers = computed(() => props.set.customers || [])
const editable = computed(() => isChildEditable(props.set.status))
function remove(row) {
proxy.$modal.confirm('确认解绑该客户?').then(() => delSetCustomer(row.customerPricesetId)).then(() => {
proxy.$modal.msgSuccess('已解绑')
emit('refresh')
}).catch(() => {})
}
</script>
<style scoped>
+1 -1
View File
@@ -18,7 +18,7 @@
<set-basic-info-card :set="set" />
<set-customer-table :set="set" />
<set-customer-table :set="set" @refresh="load" />
<set-version-table
v-model="currentPsvId"