feat(admin): require store category on HQ store create
CI / verify (pull_request) Has been cancelled
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,8 @@ export type StoreCreateForm = {
|
|||||||
city?: string;
|
city?: string;
|
||||||
district: string;
|
district: string;
|
||||||
districtCode?: string;
|
districtCode?: string;
|
||||||
|
categoryParentId?: string;
|
||||||
|
categoryId: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
address: string;
|
address: string;
|
||||||
@@ -23,11 +25,22 @@ const PHONE_RE = /^1\d{10}$/;
|
|||||||
const BANK_RE = /^\d{16,19}$/;
|
const BANK_RE = /^\d{16,19}$/;
|
||||||
|
|
||||||
export function validateStoreCreateStep1(
|
export function validateStoreCreateStep1(
|
||||||
form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
form: Pick<
|
||||||
|
StoreCreateForm,
|
||||||
|
| 'partnerAccountId'
|
||||||
|
| 'cityId'
|
||||||
|
| 'regionCodes'
|
||||||
|
| 'categoryId'
|
||||||
|
| 'name'
|
||||||
|
| 'phone'
|
||||||
|
| 'address'
|
||||||
|
| 'intro'
|
||||||
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
|
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
|
||||||
|
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
|
||||||
if (!form.name?.trim()) return '请填写门店名称';
|
if (!form.name?.trim()) return '请填写门店名称';
|
||||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ type StoreRow = {
|
|||||||
cityRef?: { name: string; code: string };
|
cityRef?: { name: string; code: string };
|
||||||
partner?: { companyName: string };
|
partner?: { companyName: string };
|
||||||
account?: { phone: string; name: string; status: string };
|
account?: { phone: string; name: string; status: string };
|
||||||
|
category?: { id: string; name: string; parentId?: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string };
|
type PartnerOption = { id: string; companyName: string };
|
||||||
@@ -272,6 +273,12 @@ type CityOption = {
|
|||||||
code: string;
|
code: string;
|
||||||
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
|
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
|
||||||
};
|
};
|
||||||
|
type CategoryNode = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status?: string;
|
||||||
|
children?: CategoryNode[];
|
||||||
|
};
|
||||||
|
|
||||||
export default function StoresPage() {
|
export default function StoresPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -301,11 +308,27 @@ export default function StoresPage() {
|
|||||||
const [createError, setCreateError] = useState('');
|
const [createError, setCreateError] = useState('');
|
||||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
const [cities, setCities] = useState<CityOption[]>([]);
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
|
|
||||||
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
||||||
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||||
const selectedCityId = Form.useWatch('cityId', createForm);
|
const selectedCityId = Form.useWatch('cityId', createForm);
|
||||||
|
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
|
||||||
|
|
||||||
|
const categoryParentOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
categoryTree
|
||||||
|
.filter((n) => n.status !== 'INACTIVE')
|
||||||
|
.map((n) => ({ value: n.id, label: n.name })),
|
||||||
|
[categoryTree],
|
||||||
|
);
|
||||||
|
const categoryChildOptions = useMemo(() => {
|
||||||
|
const parent = categoryTree.find((n) => n.id === selectedCategoryParentId);
|
||||||
|
return (parent?.children ?? [])
|
||||||
|
.filter((n) => n.status !== 'INACTIVE')
|
||||||
|
.map((n) => ({ value: n.id, label: n.name }));
|
||||||
|
}, [categoryTree, selectedCategoryParentId]);
|
||||||
|
|
||||||
function bindRegionSelection(codes: string[], partnerAccountId?: string) {
|
function bindRegionSelection(codes: string[], partnerAccountId?: string) {
|
||||||
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
|
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
|
||||||
@@ -337,16 +360,21 @@ export default function StoresPage() {
|
|||||||
setOptionsLoading(true);
|
setOptionsLoading(true);
|
||||||
try {
|
try {
|
||||||
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
|
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
|
||||||
const [p, c] = await Promise.all([
|
const [p, c, cats] = await Promise.all([
|
||||||
request<Paginated<PartnerOption>>(`/admin/partners?${qs}`),
|
request<Paginated<PartnerOption>>(`/admin/partners?${qs}`),
|
||||||
request<Paginated<CityOption>>(`/admin/cities?${qs}`),
|
request<Paginated<CityOption>>(`/admin/cities?${qs}`),
|
||||||
|
request<CategoryNode[]>('/admin/store-categories'),
|
||||||
]);
|
]);
|
||||||
setPartners(p.items);
|
setPartners(p.items);
|
||||||
setCities(c.items);
|
setCities(c.items);
|
||||||
|
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
||||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
||||||
|
if (!Array.isArray(cats) || !cats.length) {
|
||||||
|
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
|
message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败');
|
||||||
} finally {
|
} finally {
|
||||||
setOptionsLoading(false);
|
setOptionsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -379,7 +407,16 @@ export default function StoresPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createForm.validateFields(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
await createForm.validateFields([
|
||||||
|
'partnerAccountId',
|
||||||
|
'regionCodes',
|
||||||
|
'cityId',
|
||||||
|
'categoryParentId',
|
||||||
|
'categoryId',
|
||||||
|
'name',
|
||||||
|
'phone',
|
||||||
|
'address',
|
||||||
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -403,6 +440,7 @@ export default function StoresPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
partnerAccountId: values.partnerAccountId,
|
partnerAccountId: values.partnerAccountId,
|
||||||
cityId: values.cityId,
|
cityId: values.cityId,
|
||||||
|
categoryId: values.categoryId,
|
||||||
province: values.province,
|
province: values.province,
|
||||||
city: values.city,
|
city: values.city,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
@@ -441,6 +479,11 @@ export default function StoresPage() {
|
|||||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||||
},
|
},
|
||||||
{ title: '门店名', dataIndex: 'name', width: 140 },
|
{ title: '门店名', dataIndex: 'name', width: 140 },
|
||||||
|
{
|
||||||
|
title: '分类',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row) => row.category?.name || '—',
|
||||||
|
},
|
||||||
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
||||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
@@ -580,6 +623,11 @@ export default function StoresPage() {
|
|||||||
<StoreAuditMediaSection detail={detail} />
|
<StoreAuditMediaSection detail={detail} />
|
||||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店分类">
|
||||||
|
{detail.category && typeof detail.category === 'object' && 'name' in detail.category
|
||||||
|
? String((detail.category as { name?: string }).name || '—')
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="审核状态">
|
<Descriptions.Item label="审核状态">
|
||||||
<Tag color={
|
<Tag color={
|
||||||
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
||||||
@@ -685,6 +733,38 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="city" hidden><Input /></Form.Item>
|
<Form.Item name="city" hidden><Input /></Form.Item>
|
||||||
<Form.Item name="district" hidden><Input /></Form.Item>
|
<Form.Item name="district" hidden><Input /></Form.Item>
|
||||||
<Form.Item name="districtCode" hidden><Input /></Form.Item>
|
<Form.Item name="districtCode" hidden><Input /></Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="categoryParentId"
|
||||||
|
label="门店分类(大类)"
|
||||||
|
rules={[{ required: true, message: '请选择门店大类' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
loading={optionsLoading}
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder={optionsLoading ? '加载中…' : '选择大类'}
|
||||||
|
options={categoryParentOptions}
|
||||||
|
onChange={() => createForm.setFieldValue('categoryId', undefined)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="categoryId"
|
||||||
|
label="门店分类(细类)"
|
||||||
|
rules={[{ required: true, message: '请选择门店细类' }]}
|
||||||
|
extra={
|
||||||
|
<Typography.Link onClick={() => navigate('/store-categories')}>
|
||||||
|
去配置门店分类
|
||||||
|
</Typography.Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder={selectedCategoryParentId ? '选择细类' : '请先选大类'}
|
||||||
|
disabled={!selectedCategoryParentId}
|
||||||
|
options={categoryChildOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
||||||
<Input placeholder="请输入门店名称" />
|
<Input placeholder="请输入门店名称" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
|||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
|
import { StoreCategoryService } from '../store/store-category.service';
|
||||||
import type {
|
import type {
|
||||||
CreateStoreAccountDto,
|
CreateStoreAccountDto,
|
||||||
CreateStoreDto,
|
CreateStoreDto,
|
||||||
@@ -20,6 +21,7 @@ export class AdminStoresService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly partnerCityService: PartnerCityService,
|
private readonly partnerCityService: PartnerCityService,
|
||||||
|
private readonly storeCategoryService: StoreCategoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listStores(query: AdminStoresQueryDto) {
|
async listStores(query: AdminStoresQueryDto) {
|
||||||
@@ -44,6 +46,7 @@ export class AdminStoresService {
|
|||||||
include: {
|
include: {
|
||||||
cityRef: { select: { id: true, name: true, code: true } },
|
cityRef: { select: { id: true, name: true, code: true } },
|
||||||
partnerAccount: { select: { id: true, companyName: true } },
|
partnerAccount: { select: { id: true, companyName: true } },
|
||||||
|
category: { select: { id: true, name: true, parentId: true } },
|
||||||
bindings: {
|
bindings: {
|
||||||
where: { storeAccount: { isPrimary: 1 } },
|
where: { storeAccount: { isPrimary: 1 } },
|
||||||
take: 1,
|
take: 1,
|
||||||
@@ -237,12 +240,18 @@ export class AdminStoresService {
|
|||||||
if (!city) throw new BadRequestException('开城城市不存在');
|
if (!city) throw new BadRequestException('开城城市不存在');
|
||||||
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
|
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
|
||||||
|
|
||||||
|
if (!dto.categoryId?.trim()) {
|
||||||
|
throw new BadRequestException('请选择门店分类');
|
||||||
|
}
|
||||||
|
const categoryId = BigInt(dto.categoryId);
|
||||||
|
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||||
|
|
||||||
const store = await this.prisma.store.create({
|
const store = await this.prisma.store.create({
|
||||||
data: {
|
data: {
|
||||||
cityId: city.id,
|
cityId: city.id,
|
||||||
partnerAccountId,
|
partnerAccountId,
|
||||||
settlementRate: dto.settlementRate ?? 0.6,
|
settlementRate: dto.settlementRate ?? 0.6,
|
||||||
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
|
categoryId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
phone: normalizedPhone,
|
phone: normalizedPhone,
|
||||||
province: dto.province ?? city.province,
|
province: dto.province ?? city.province,
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ export class CreateStoreDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
categoryId?: string;
|
@IsNotEmpty()
|
||||||
|
categoryId: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
Reference in New Issue
Block a user