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;
|
||||
district: string;
|
||||
districtCode?: string;
|
||||
categoryParentId?: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
@@ -23,11 +25,22 @@ const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
|
||||
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 {
|
||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
|
||||
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
|
||||
if (!form.name?.trim()) return '请填写门店名称';
|
||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||
|
||||
@@ -263,6 +263,7 @@ type StoreRow = {
|
||||
cityRef?: { name: string; code: string };
|
||||
partner?: { companyName: string };
|
||||
account?: { phone: string; name: string; status: string };
|
||||
category?: { id: string; name: string; parentId?: string | null } | null;
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
@@ -272,6 +273,12 @@ type CityOption = {
|
||||
code: string;
|
||||
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
|
||||
};
|
||||
type CategoryNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
children?: CategoryNode[];
|
||||
};
|
||||
|
||||
export default function StoresPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -301,11 +308,27 @@ export default function StoresPage() {
|
||||
const [createError, setCreateError] = useState('');
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||
|
||||
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
||||
const selectedRegionCodes = Form.useWatch('regionCodes', 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) {
|
||||
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
|
||||
@@ -337,16 +360,21 @@ export default function StoresPage() {
|
||||
setOptionsLoading(true);
|
||||
try {
|
||||
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<CityOption>>(`/admin/cities?${qs}`),
|
||||
request<CategoryNode[]>('/admin/store-categories'),
|
||||
]);
|
||||
setPartners(p.items);
|
||||
setCities(c.items);
|
||||
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
||||
if (!Array.isArray(cats) || !cats.length) {
|
||||
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
|
||||
message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败');
|
||||
} finally {
|
||||
setOptionsLoading(false);
|
||||
}
|
||||
@@ -379,7 +407,16 @@ export default function StoresPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createForm.validateFields(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
||||
await createForm.validateFields([
|
||||
'partnerAccountId',
|
||||
'regionCodes',
|
||||
'cityId',
|
||||
'categoryParentId',
|
||||
'categoryId',
|
||||
'name',
|
||||
'phone',
|
||||
'address',
|
||||
]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -403,6 +440,7 @@ export default function StoresPage() {
|
||||
body: JSON.stringify({
|
||||
partnerAccountId: values.partnerAccountId,
|
||||
cityId: values.cityId,
|
||||
categoryId: values.categoryId,
|
||||
province: values.province,
|
||||
city: values.city,
|
||||
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 }} /> : '—',
|
||||
},
|
||||
{ title: '门店名', dataIndex: 'name', width: 140 },
|
||||
{
|
||||
title: '分类',
|
||||
width: 100,
|
||||
render: (_, row) => row.category?.name || '—',
|
||||
},
|
||||
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
@@ -580,6 +623,11 @@ export default function StoresPage() {
|
||||
<StoreAuditMediaSection detail={detail} />
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<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="审核状态">
|
||||
<Tag color={
|
||||
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
||||
@@ -685,6 +733,38 @@ export default function StoresPage() {
|
||||
<Form.Item name="city" hidden><Input /></Form.Item>
|
||||
<Form.Item name="district" 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: '请填写门店名称' }]}>
|
||||
<Input placeholder="请输入门店名称" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { StoreCategoryService } from '../store/store-category.service';
|
||||
import type {
|
||||
CreateStoreAccountDto,
|
||||
CreateStoreDto,
|
||||
@@ -20,6 +21,7 @@ export class AdminStoresService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
) {}
|
||||
|
||||
async listStores(query: AdminStoresQueryDto) {
|
||||
@@ -44,6 +46,7 @@ export class AdminStoresService {
|
||||
include: {
|
||||
cityRef: { select: { id: true, name: true, code: true } },
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
category: { select: { id: true, name: true, parentId: true } },
|
||||
bindings: {
|
||||
where: { storeAccount: { isPrimary: 1 } },
|
||||
take: 1,
|
||||
@@ -237,12 +240,18 @@ export class AdminStoresService {
|
||||
if (!city) throw new BadRequestException('开城城市不存在');
|
||||
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({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
partnerAccountId,
|
||||
settlementRate: dto.settlementRate ?? 0.6,
|
||||
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
|
||||
categoryId,
|
||||
name: dto.name,
|
||||
phone: normalizedPhone,
|
||||
province: dto.province ?? city.province,
|
||||
|
||||
@@ -37,9 +37,9 @@ export class CreateStoreDto {
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
@IsNotEmpty()
|
||||
categoryId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
Reference in New Issue
Block a user