feat(admin): require business hours when creating stores
CI / verify (pull_request) Has been cancelled
CI / verify (pull_request) Has been cancelled
HQ create-store wizard now collects 1-2 hour segments and avgPrice, and API validates them on create/update. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,11 @@ export type StoreCreateForm = {
|
|||||||
phone: string;
|
phone: string;
|
||||||
address: string;
|
address: string;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
|
openTime?: string;
|
||||||
|
closeTime?: string;
|
||||||
|
openTime2?: string;
|
||||||
|
closeTime2?: string;
|
||||||
|
avgPrice?: number | null;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
envPhotoUrls?: string[];
|
envPhotoUrls?: string[];
|
||||||
contractUrl?: string;
|
contractUrl?: string;
|
||||||
@@ -23,6 +28,12 @@ export type StoreCreateForm = {
|
|||||||
|
|
||||||
const PHONE_RE = /^1\d{10}$/;
|
const PHONE_RE = /^1\d{10}$/;
|
||||||
const BANK_RE = /^\d{16,19}$/;
|
const BANK_RE = /^\d{16,19}$/;
|
||||||
|
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||||
|
|
||||||
|
function timeToMinutes(hhmm: string): number {
|
||||||
|
const [h, m] = hhmm.split(':').map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateStoreCreateStep1(
|
export function validateStoreCreateStep1(
|
||||||
form: Pick<
|
form: Pick<
|
||||||
@@ -35,6 +46,11 @@ export function validateStoreCreateStep1(
|
|||||||
| 'phone'
|
| 'phone'
|
||||||
| 'address'
|
| 'address'
|
||||||
| 'intro'
|
| 'intro'
|
||||||
|
| 'openTime'
|
||||||
|
| 'closeTime'
|
||||||
|
| 'openTime2'
|
||||||
|
| 'closeTime2'
|
||||||
|
| 'avgPrice'
|
||||||
>,
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||||
@@ -45,6 +61,27 @@ export function validateStoreCreateStep1(
|
|||||||
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位手机号';
|
||||||
if (!form.address?.trim()) return '请填写详细地址';
|
if (!form.address?.trim()) return '请填写详细地址';
|
||||||
|
|
||||||
|
const openTime = String(form.openTime || '').trim();
|
||||||
|
const closeTime = String(form.closeTime || '').trim();
|
||||||
|
if (!openTime || !closeTime) return '请填写营业时间';
|
||||||
|
if (!TIME_RE.test(openTime) || !TIME_RE.test(closeTime)) return '营业时间格式须为 HH:MM';
|
||||||
|
if (timeToMinutes(openTime) >= timeToMinutes(closeTime)) return '营业结束时间须晚于开始时间';
|
||||||
|
|
||||||
|
const openTime2 = String(form.openTime2 || '').trim();
|
||||||
|
const closeTime2 = String(form.closeTime2 || '').trim();
|
||||||
|
if (openTime2 || closeTime2) {
|
||||||
|
if (!openTime2 || !closeTime2) return '请完整填写第二段营业时间';
|
||||||
|
if (!TIME_RE.test(openTime2) || !TIME_RE.test(closeTime2)) return '第二段时间格式须为 HH:MM';
|
||||||
|
if (timeToMinutes(openTime2) >= timeToMinutes(closeTime2)) return '第二段结束时间须晚于开始时间';
|
||||||
|
if (timeToMinutes(closeTime) >= timeToMinutes(openTime2)) return '第二段开始时间须晚于第一段结束时间';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.avgPrice != null && String(form.avgPrice).trim() !== '') {
|
||||||
|
const n = Number(form.avgPrice);
|
||||||
|
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
|
||||||
|
}
|
||||||
|
|
||||||
if (form.intro?.trim()) {
|
if (form.intro?.trim()) {
|
||||||
const len = form.intro.trim().length;
|
const len = form.intro.trim().length;
|
||||||
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
||||||
|
|||||||
@@ -405,6 +405,11 @@ export default function StoresPage() {
|
|||||||
createForm.setFieldsValue({
|
createForm.setFieldsValue({
|
||||||
envPhotoUrls: ['', '', ''],
|
envPhotoUrls: ['', '', ''],
|
||||||
settlementRate: 60,
|
settlementRate: 60,
|
||||||
|
openTime: '10:00',
|
||||||
|
closeTime: '22:00',
|
||||||
|
openTime2: undefined,
|
||||||
|
closeTime2: undefined,
|
||||||
|
avgPrice: undefined,
|
||||||
});
|
});
|
||||||
setCreateStep(0);
|
setCreateStep(0);
|
||||||
setCreateError('');
|
setCreateError('');
|
||||||
@@ -461,6 +466,14 @@ export default function StoresPage() {
|
|||||||
district: values.district.trim(),
|
district: values.district.trim(),
|
||||||
address: values.address.trim(),
|
address: values.address.trim(),
|
||||||
intro: values.intro?.trim() || undefined,
|
intro: values.intro?.trim() || undefined,
|
||||||
|
openTime: values.openTime?.trim() || '10:00',
|
||||||
|
closeTime: values.closeTime?.trim() || '22:00',
|
||||||
|
...(values.openTime2?.trim() && values.closeTime2?.trim()
|
||||||
|
? { openTime2: values.openTime2.trim(), closeTime2: values.closeTime2.trim() }
|
||||||
|
: {}),
|
||||||
|
...(values.avgPrice != null && values.avgPrice !== ''
|
||||||
|
? { avgPrice: Number(values.avgPrice) }
|
||||||
|
: {}),
|
||||||
coverUrl: values.coverUrl?.trim() || undefined,
|
coverUrl: values.coverUrl?.trim() || undefined,
|
||||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||||
contractUrl: values.contractUrl?.trim() || undefined,
|
contractUrl: values.contractUrl?.trim() || undefined,
|
||||||
@@ -841,6 +854,25 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||||
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Space wrap style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="openTime" label="营业开始" rules={[{ required: true, message: '请填写营业开始时间' }]}>
|
||||||
|
<Input type="time" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true, message: '请填写营业结束时间' }]}>
|
||||||
|
<Input type="time" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Space wrap style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="openTime2" label="第二段开始(选填)">
|
||||||
|
<Input type="time" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="closeTime2" label="第二段结束">
|
||||||
|
<Input type="time" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name="avgPrice" label="人均费用(选填)">
|
||||||
|
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="intro" label="门店简介">
|
<Form.Item name="intro" label="门店简介">
|
||||||
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} />
|
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { validateBusinessHours } from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
@@ -176,6 +177,23 @@ export class AdminStoresService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateStore(id: bigint, dto: UpdateStoreDto) {
|
async updateStore(id: bigint, dto: UpdateStoreDto) {
|
||||||
|
const current = await this.prisma.store.findUnique({ where: { id } });
|
||||||
|
if (!current) throw new NotFoundException('门店不存在');
|
||||||
|
|
||||||
|
const openTime = dto.openTime !== undefined ? dto.openTime?.trim() || '' : current.openTime || '';
|
||||||
|
const closeTime = dto.closeTime !== undefined ? dto.closeTime?.trim() || '' : current.closeTime || '';
|
||||||
|
const openTime2 =
|
||||||
|
dto.openTime2 !== undefined ? dto.openTime2?.trim() || '' : current.openTime2 || '';
|
||||||
|
const closeTime2 =
|
||||||
|
dto.closeTime2 !== undefined ? dto.closeTime2?.trim() || '' : current.closeTime2 || '';
|
||||||
|
if (dto.openTime !== undefined || dto.closeTime !== undefined || dto.openTime2 !== undefined || dto.closeTime2 !== undefined) {
|
||||||
|
const hoursCheck = validateBusinessHours([
|
||||||
|
{ open: openTime || '10:00', close: closeTime || '22:00' },
|
||||||
|
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
|
||||||
|
]);
|
||||||
|
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
|
||||||
|
}
|
||||||
|
|
||||||
const store = await this.prisma.store.update({
|
const store = await this.prisma.store.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -194,7 +212,6 @@ export class AdminStoresService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (dto.coverUrl) {
|
if (dto.coverUrl) {
|
||||||
const current = await this.prisma.store.findUniqueOrThrow({ where: { id } });
|
|
||||||
if (current.coverResourceId) {
|
if (current.coverResourceId) {
|
||||||
await this.prisma.commonResource.update({
|
await this.prisma.commonResource.update({
|
||||||
where: { id: current.coverResourceId },
|
where: { id: current.coverResourceId },
|
||||||
@@ -251,6 +268,16 @@ export class AdminStoresService {
|
|||||||
const categoryId = BigInt(dto.categoryId);
|
const categoryId = BigInt(dto.categoryId);
|
||||||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||||
|
|
||||||
|
const openTime = dto.openTime?.trim() || '10:00';
|
||||||
|
const closeTime = dto.closeTime?.trim() || '22:00';
|
||||||
|
const openTime2 = dto.openTime2?.trim() || '';
|
||||||
|
const closeTime2 = dto.closeTime2?.trim() || '';
|
||||||
|
const hoursCheck = validateBusinessHours([
|
||||||
|
{ open: openTime, close: closeTime },
|
||||||
|
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
|
||||||
|
]);
|
||||||
|
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
|
||||||
|
|
||||||
const store = await this.prisma.store.create({
|
const store = await this.prisma.store.create({
|
||||||
data: {
|
data: {
|
||||||
cityId: city.id,
|
cityId: city.id,
|
||||||
@@ -265,10 +292,10 @@ export class AdminStoresService {
|
|||||||
address: dto.address,
|
address: dto.address,
|
||||||
intro: dto.intro ?? null,
|
intro: dto.intro ?? null,
|
||||||
avgPrice: dto.avgPrice ?? null,
|
avgPrice: dto.avgPrice ?? null,
|
||||||
openTime: dto.openTime?.trim() || '10:00',
|
openTime,
|
||||||
closeTime: dto.closeTime?.trim() || '22:00',
|
closeTime,
|
||||||
openTime2: dto.openTime2?.trim() || null,
|
openTime2: openTime2 || null,
|
||||||
closeTime2: dto.closeTime2?.trim() || null,
|
closeTime2: closeTime2 || null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
auditStatus: 'APPROVED',
|
auditStatus: 'APPROVED',
|
||||||
auditedAt: new Date(),
|
auditedAt: new Date(),
|
||||||
|
|||||||
Reference in New Issue
Block a user