diff --git a/apps/admin-web/src/components/ProductSpecsEditor.tsx b/apps/admin-web/src/components/ProductSpecsEditor.tsx index 84fccba..60f7821 100644 --- a/apps/admin-web/src/components/ProductSpecsEditor.tsx +++ b/apps/admin-web/src/components/ProductSpecsEditor.tsx @@ -157,8 +157,7 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku const payloadSkus = skus.map((s, i) => ({ id: s.id, specValueIds: resolveIds(s.specValueIds ?? []), - skuCode: s.skuCode, - barcode69: s.barcode69, + barcode69: s.barcode69.trim(), price: s.price, benefitAmount: s.benefitAmount, status: s.status, @@ -171,8 +170,10 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku sortOrder: i, })); - for (const row of payloadSkus) { - if (!row.barcode69?.trim()) throw new Error('请填写全部 SKU 的 69 码'); + const barcodes = payloadSkus.map((row) => row.barcode69); + if (barcodes.some((b) => !b)) throw new Error('每个规格须填写独立 69 码'); + if (new Set(barcodes).size !== barcodes.length) { + throw new Error('同一商品内 69 码不可重复,每个规格请填写不同 69 码'); } await request(`/admin/products/${productId}/skus`, { @@ -191,7 +192,8 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku return (
- 先配置销售规格轴(如「包装」),再生成 SKU 矩阵。未配置规格时仅保留默认一行 SKU,与基础信息价/码同步。 + SKU 码由系统自动生成(DK 开头),无需填写。每个规格必须填写互不相同的 69 码。 + 先配置销售规格轴(如「包装」),再生成 SKU 矩阵;未配置规格时仅保留默认一行。 规格轴 @@ -272,7 +274,7 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku size="small" rowKey={(_, i) => String(i)} pagination={false} - scroll={{ x: 1100 }} + scroll={{ x: 1240 }} dataSource={skus} columns={[ { @@ -281,10 +283,20 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'), }, { - title: '69码', - width: 140, + title: 'SKU', + width: 110, + render: (_, row) => ( + + {row.skuCode || '保存后自动生成'} + + ), + }, + { + title: 69码 *, + width: 180, render: (_, row, index) => ( { const next = [...skus]; diff --git a/apps/admin-web/src/pages/InvoicesPage.tsx b/apps/admin-web/src/pages/InvoicesPage.tsx index 0f6e018..22dbc4a 100644 --- a/apps/admin-web/src/pages/InvoicesPage.tsx +++ b/apps/admin-web/src/pages/InvoicesPage.tsx @@ -17,9 +17,11 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { + INVOICE_CATEGORY_LABELS, INVOICE_KIND_LABELS, INVOICE_STATUS_LABELS, INVOICE_TITLE_TYPE_LABELS, + type InvoiceCategory, type InvoiceKind, type InvoiceStatus, type InvoiceTitleType, @@ -35,6 +37,7 @@ type Row = { orderNo?: string; titleType: InvoiceTitleType; invoiceKind: InvoiceKind; + invoiceCategory?: InvoiceCategory; titleName: string; status: InvoiceStatus; overdue?: boolean; @@ -54,6 +57,7 @@ type CreateFormValues = { orderNo: string; titleType: InvoiceTitleType; invoiceKind: InvoiceKind; + invoiceCategory?: InvoiceCategory; titleName: string; taxNo?: string; addressPhone?: string; @@ -156,6 +160,7 @@ export default function InvoicesPage() { orderNo: values.orderNo.trim(), titleType: values.titleType, invoiceKind: values.invoiceKind, + invoiceCategory: values.invoiceCategory, titleName: values.titleName.trim(), taxNo: values.taxNo?.trim() || undefined, addressPhone: values.addressPhone?.trim() || undefined, @@ -186,9 +191,17 @@ export default function InvoicesPage() { }, { title: '票种', - width: 120, + width: 140, render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind, }, + { + title: '类型', + width: 88, + render: (_, r) => + r.invoiceCategory + ? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory + : '—', + }, { title: '名称', dataIndex: 'titleName', ellipsis: true }, { title: '状态', @@ -233,6 +246,7 @@ export default function InvoicesPage() { createForm.setFieldsValue({ titleType: 'PERSONAL', invoiceKind: 'NORMAL', + invoiceCategory: 'LIQUOR', }); setCreateOpen(true); }} @@ -324,6 +338,11 @@ export default function InvoicesPage() { {INVOICE_KIND_LABELS[detail.invoiceKind]} + + {detail.invoiceCategory + ? INVOICE_CATEGORY_LABELS[detail.invoiceCategory] + : '—'} + {detail.titleName} {detail.taxNo ?? '—'} {detail.addressPhone ?? '—'} @@ -382,6 +401,18 @@ export default function InvoicesPage() { }} /> + + - - + + ) : null} + + {canApply ? ( + + 备注 + setRemark(e.detail.value)} + /> + + ) : null} {canApply && titles.length > 0 ? ( diff --git a/apps/mini-user/src/pages/invoice-titles/index.tsx b/apps/mini-user/src/pages/invoice-titles/index.tsx index 5d5c15e..0abb4af 100644 --- a/apps/mini-user/src/pages/invoice-titles/index.tsx +++ b/apps/mini-user/src/pages/invoice-titles/index.tsx @@ -26,6 +26,24 @@ const ACTION_ICONS = { type EditDraft = UpsertInvoiceTitleRequest & { id?: string }; +function FieldLabel({ + children, + required, + hint, +}: { + children: string; + required?: boolean; + hint?: string; +}) { + return ( + + {required ? * : null} + {children} + {hint ? {hint} : null} + + ); +} + function TitleActionIcon({ kind, label, @@ -120,6 +138,15 @@ export default function InvoiceTitlesPage() { toast('企业抬头须填写税号'); return; } + const email = draft.email?.trim() || ''; + if (!email) { + toast('请填写接收邮箱(必填)'); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + toast('邮箱格式不正确'); + return; + } setSaving(true); try { const payload: UpsertInvoiceTitleRequest = { @@ -152,6 +179,10 @@ export default function InvoiceTitlesPage() { } async function setDefault(t: UserInvoiceTitleDto) { + if (!t.email?.trim()) { + toast('请先补全接收邮箱后再设为默认'); + return; + } try { await request(`/trade/invoice-titles/${t.id}`, { method: 'PUT', @@ -241,7 +272,7 @@ export default function InvoiceTitlesPage() { - 抬头类型 + 抬头类型 {(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => ( - 抬头名称 + 抬头名称 - 税号 + 税号 - 接收邮箱 + + 接收邮箱 + setDraft({ ...draft, email: e.detail.value })} /> @@ -311,7 +344,7 @@ export default function InvoiceTitlesPage() { setDraft({ ...draft, addressPhone: e.detail.value })} /> @@ -321,7 +354,7 @@ export default function InvoiceTitlesPage() { setDraft({ ...draft, bankAccount: e.detail.value })} /> diff --git a/apps/mini-user/src/pages/pay/index.tsx b/apps/mini-user/src/pages/pay/index.tsx index bc4f3f6..2ede5b1 100644 --- a/apps/mini-user/src/pages/pay/index.tsx +++ b/apps/mini-user/src/pages/pay/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { View, Text } from '@tarojs/components'; +import { View, Text, Image } from '@tarojs/components'; import '../../styles/order.css'; import Taro, { useDidShow, useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; @@ -16,6 +16,7 @@ import { saveWechatLoginResult, } from '../../lib/pay-wechat'; import { applyWechatLoginResult } from '../../lib/wechat-auth'; +import { getBrandAssetsSync, loadBrandAssets } from '../../lib/brand-assets'; import { isWechatEnv } from '../../lib/weixin'; import { goLogin } from '../../lib/auth-nav'; import { request, toast } from '../../lib/api'; @@ -25,12 +26,12 @@ export default function PayPage() { const orderId = router.params.orderId ?? ''; const [loading, setLoading] = useState(false); const [authLoading, setAuthLoading] = useState(false); - const [mockMode, setMockMode] = useState(true); const [needsWechatAuth, setNeedsWechatAuth] = useState(false); const [msg, setMsg] = useState(''); const [orderNo, setOrderNo] = useState(''); const [payAmount, setPayAmount] = useState('—'); const [deliveryType, setDeliveryType] = useState(''); + const [brandMarkUrl, setBrandMarkUrl] = useState(() => getBrandAssetsSync().brandLogoMarkUrl); const returnPath = orderId ? `/pages/pay/index?orderId=${orderId}` @@ -39,7 +40,6 @@ export default function PayPage() { const refreshPayReadiness = useCallback(async () => { try { const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]); - setMockMode(config.mockPay); setNeedsWechatAuth(needsWechatAuthForPay(config, profile)); return profile; } catch { @@ -51,6 +51,10 @@ export default function PayPage() { void refreshPayReadiness(); }); + useEffect(() => { + void loadBrandAssets().then((brand) => setBrandMarkUrl(brand.brandLogoMarkUrl)); + }, []); + useEffect(() => { if (!orderId) return; if (process.env.TARO_ENV === 'weapp') { @@ -174,7 +178,7 @@ export default function PayPage() { - ¥ + {needsWechatAuth ? '需完成微信授权' : '待支付'} @@ -204,12 +208,6 @@ export default function PayPage() { 支付方式 微信支付 - - 说明 - - {mockMode ? 'Mock 模式由服务端直接标记已付款' : '将调起微信收银台'} - - {msg ? ( diff --git a/apps/mini-user/src/styles/invoice.css b/apps/mini-user/src/styles/invoice.css index f98e045..2eef2fb 100644 --- a/apps/mini-user/src/styles/invoice.css +++ b/apps/mini-user/src/styles/invoice.css @@ -153,6 +153,30 @@ margin-bottom: 6px; } +.invoice-title-star { + color: #c62828; + margin-right: 2px; + font-weight: 600; +} + +.invoice-title-label-hint { + margin-left: 6px; + font-size: 12px; + color: #c62828; + font-weight: 500; +} + +.invoice-kind-static { + display: block; + font-size: 15px; + color: var(--color-on-surface, #1a1a1a); + line-height: 44px; + padding: 0 12px; + background: var(--color-surface, #fafafa); + border: 1px solid var(--color-outline, #ddd); + border-radius: 8px; +} + .invoice-title-input { width: 100%; height: 44px; diff --git a/apps/mini-user/src/styles/order.css b/apps/mini-user/src/styles/order.css index 89bd21b..7f3484b 100644 --- a/apps/mini-user/src/styles/order.css +++ b/apps/mini-user/src/styles/order.css @@ -498,6 +498,12 @@ align-items: center; justify-content: center; margin: 0 auto 16px; + overflow: hidden; +} + +.pay-status-brand { + width: 44px; + height: 44px; } .pay-status-title { diff --git a/packages/shared-types/src/catalog.ts b/packages/shared-types/src/catalog.ts index b389297..5005ff1 100644 --- a/packages/shared-types/src/catalog.ts +++ b/packages/shared-types/src/catalog.ts @@ -129,6 +129,7 @@ export interface AdminProductSkuInput { id?: string; /** 规格值 id;无规格时为空数组 */ specValueIds?: string[]; + /** 忽略;服务端自动生成 DK 码 */ skuCode?: string; barcode69: string; price: number; diff --git a/packages/shared-types/src/invoice.ts b/packages/shared-types/src/invoice.ts index 595a5dd..2b8b5be 100644 --- a/packages/shared-types/src/invoice.ts +++ b/packages/shared-types/src/invoice.ts @@ -1,5 +1,7 @@ export type InvoiceTitleType = 'PERSONAL' | 'ENTERPRISE'; export type InvoiceKind = 'NORMAL' | 'SPECIAL'; +/** C 端开票品类:酒水类 / 餐饮类(票种固定增值税普通发票) */ +export type InvoiceCategory = 'LIQUOR' | 'CATERING'; export type InvoiceStatus = 'PENDING' | 'ISSUED' | 'REJECTED'; export const INVOICE_TITLE_TYPE_LABELS: Record = { @@ -12,6 +14,11 @@ export const INVOICE_KIND_LABELS: Record = { SPECIAL: '增值税专用发票', }; +export const INVOICE_CATEGORY_LABELS: Record = { + LIQUOR: '酒水类', + CATERING: '餐饮类', +}; + export const INVOICE_STATUS_LABELS: Record = { PENDING: '待开票', ISSUED: '已开票', @@ -21,6 +28,7 @@ export const INVOICE_STATUS_LABELS: Record = { export interface CreateInvoiceRequest { titleType: InvoiceTitleType; invoiceKind: InvoiceKind; + invoiceCategory?: InvoiceCategory; titleName: string; taxNo?: string; addressPhone?: string; @@ -37,6 +45,7 @@ export interface InvoiceDto { userId: string; titleType: InvoiceTitleType; invoiceKind: InvoiceKind; + invoiceCategory?: InvoiceCategory; titleName: string; taxNo?: string | null; addressPhone?: string | null; diff --git a/server/dukang-api/prisma/backfill-product-skus.ts b/server/dukang-api/prisma/backfill-product-skus.ts index 14ecb67..3a60ac8 100644 --- a/server/dukang-api/prisma/backfill-product-skus.ts +++ b/server/dukang-api/prisma/backfill-product-skus.ts @@ -5,6 +5,30 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); +const PREFIX = 'DK'; +const PAD = 6; +const RE = /^DK(\d+)$/; + +async function nextDkCode() { + const [items, skus] = await Promise.all([ + prisma.commonProductItem.findMany({ + where: { skuCode: { startsWith: PREFIX } }, + select: { skuCode: true }, + }), + prisma.commonProductSku.findMany({ + where: { skuCode: { startsWith: PREFIX } }, + select: { skuCode: true }, + }), + ]); + let maxSeq = 0; + for (const row of [...items, ...skus]) { + const m = RE.exec(row.skuCode); + if (!m) continue; + const n = Number(m[1]); + if (Number.isFinite(n) && n > maxSeq) maxSeq = n; + } + return `${PREFIX}${String(maxSeq + 1).padStart(PAD, '0')}`; +} async function main() { const products = await prisma.commonProductItem.findMany({ @@ -13,10 +37,11 @@ async function main() { let created = 0; for (const p of products) { if (p.skus.length > 0) continue; + const skuCode = RE.test(p.skuCode) ? p.skuCode : await nextDkCode(); await prisma.commonProductSku.create({ data: { productId: p.id, - skuCode: p.skuCode, + skuCode, barcode69: p.barcode69, specKey: '', specText: p.spec, @@ -32,8 +57,14 @@ async function main() { sortOrder: 0, }, }); + if (p.skuCode !== skuCode) { + await prisma.commonProductItem.update({ + where: { id: p.id }, + data: { skuCode }, + }); + } created += 1; - console.log(`created default sku for product ${p.id} ${p.skuCode}`); + console.log(`created default sku for product ${p.id} ${skuCode}`); } console.log(`done, created=${created}, scanned=${products.length}`); } diff --git a/server/dukang-api/prisma/migrate-invoice-category.sql b/server/dukang-api/prisma/migrate-invoice-category.sql new file mode 100644 index 0000000..8d7fb87 --- /dev/null +++ b/server/dukang-api/prisma/migrate-invoice-category.sql @@ -0,0 +1,5 @@ +-- 发票开票品类:酒水类 / 餐饮类(票种仍为增值税普通发票) +-- 存量申请默认酒水类,与 Prisma InvoiceCategory 默认值一致 + +ALTER TABLE `user_invoice` + ADD COLUMN `invoice_category` VARCHAR(16) NOT NULL DEFAULT 'LIQUOR' AFTER `invoice_kind`; diff --git a/server/dukang-api/prisma/rewrite-sku-codes-dk.ts b/server/dukang-api/prisma/rewrite-sku-codes-dk.ts new file mode 100644 index 0000000..b4b468d --- /dev/null +++ b/server/dukang-api/prisma/rewrite-sku-codes-dk.ts @@ -0,0 +1,147 @@ +/** + * 将存量商品 / SKU 的 sku_code 全部改成 DK + 6 位数字。 + * 已是 DK 数字码的保持不变;其余先写临时码再分配,避免唯一约束冲突。 + * + * 用法:cd server/dukang-api && npx ts-node prisma/rewrite-sku-codes-dk.ts + */ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); +const PREFIX = 'DK'; +const PAD = 6; +const RE = /^DK(\d+)$/; + +function isDk(code: string) { + return RE.test(code); +} + +function formatCode(seq: number) { + return `${PREFIX}${String(seq).padStart(PAD, '0')}`; +} + +async function maxExistingSeq(): Promise { + const [items, skus] = await Promise.all([ + prisma.commonProductItem.findMany({ + where: { skuCode: { startsWith: PREFIX } }, + select: { skuCode: true }, + }), + prisma.commonProductSku.findMany({ + where: { skuCode: { startsWith: PREFIX } }, + select: { skuCode: true }, + }), + ]); + let maxSeq = 0; + for (const row of [...items, ...skus]) { + const m = RE.exec(row.skuCode); + if (!m) continue; + const n = Number(m[1]); + if (Number.isFinite(n) && n > maxSeq) maxSeq = n; + } + return maxSeq; +} + +async function main() { + const skus = await prisma.commonProductSku.findMany({ + orderBy: [{ productId: 'asc' }, { id: 'asc' }], + select: { id: true, productId: true, skuCode: true, isDefault: true }, + }); + const products = await prisma.commonProductItem.findMany({ + select: { id: true, skuCode: true }, + }); + + const skuNeed = skus.filter((s) => !isDk(s.skuCode)); + const productNeed = products.filter((p) => !isDk(p.skuCode)); + console.log(`sku total=${skus.length} rewrite=${skuNeed.length}`); + console.log(`product total=${products.length} rewrite=${productNeed.length}`); + + await prisma.$transaction(async (tx) => { + for (const s of skuNeed) { + await tx.commonProductSku.update({ + where: { id: s.id }, + data: { skuCode: `TMP${s.id.toString()}` }, + }); + } + for (const p of productNeed) { + await tx.commonProductItem.update({ + where: { id: p.id }, + data: { skuCode: `TMPP${p.id.toString()}` }, + }); + } + + const seqStart = await (async () => { + const [items, skuRows] = await Promise.all([ + tx.commonProductItem.findMany({ + where: { skuCode: { startsWith: PREFIX } }, + select: { skuCode: true }, + }), + tx.commonProductSku.findMany({ + where: { skuCode: { startsWith: PREFIX } }, + select: { skuCode: true }, + }), + ]); + let maxSeq = 0; + for (const row of [...items, ...skuRows]) { + const m = RE.exec(row.skuCode); + if (!m) continue; + const n = Number(m[1]); + if (Number.isFinite(n) && n > maxSeq) maxSeq = n; + } + return maxSeq; + })(); + + let seq = seqStart; + const defaultSkuCodeByProduct = new Map(); + + for (const s of skuNeed) { + seq += 1; + const code = formatCode(seq); + await tx.commonProductSku.update({ + where: { id: s.id }, + data: { skuCode: code }, + }); + if (s.isDefault) defaultSkuCodeByProduct.set(s.productId.toString(), code); + console.log(`sku ${s.id} ${s.skuCode} -> ${code}`); + } + + for (const p of productNeed) { + const fromDefault = defaultSkuCodeByProduct.get(p.id.toString()); + let code = fromDefault; + if (!code) { + seq += 1; + code = formatCode(seq); + } + await tx.commonProductItem.update({ + where: { id: p.id }, + data: { skuCode: code }, + }); + console.log(`product ${p.id} ${p.skuCode} -> ${code}`); + } + + const stillDefault = await tx.commonProductSku.findMany({ + where: { isDefault: true }, + select: { productId: true, skuCode: true }, + }); + for (const s of stillDefault) { + if (!isDk(s.skuCode)) continue; + await tx.commonProductItem.updateMany({ + where: { id: s.productId, NOT: { skuCode: s.skuCode } }, + data: { skuCode: s.skuCode }, + }); + } + }); + + const leftoverSku = await prisma.commonProductSku.count({ + where: { NOT: { skuCode: { startsWith: PREFIX } } }, + }); + const leftoverProduct = await prisma.commonProductItem.count({ + where: { NOT: { skuCode: { startsWith: PREFIX } } }, + }); + console.log(`done leftoverSku=${leftoverSku} leftoverProduct=${leftoverProduct} maxWas=${await maxExistingSeq()}`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 2ddeb17..e02d471 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -146,6 +146,12 @@ enum InvoiceKind { SPECIAL } +/// C 端开票品类(增值税票种固定普通发票) +enum InvoiceCategory { + LIQUOR + CATERING +} + enum InvoiceStatus { PENDING ISSUED @@ -1678,6 +1684,8 @@ model UserInvoice { userId BigInt @map("user_id") @db.UnsignedBigInt titleType InvoiceTitleType @map("title_type") invoiceKind InvoiceKind @map("invoice_kind") + /// 酒水类 / 餐饮类;历史单默认酒水类 + invoiceCategory InvoiceCategory @default(LIQUOR) @map("invoice_category") titleName String @map("title_name") @db.VarChar(128) taxNo String? @map("tax_no") @db.VarChar(32) addressPhone String? @map("address_phone") @db.VarChar(256) diff --git a/server/dukang-api/src/modules/catalog/catalog.service.ts b/server/dukang-api/src/modules/catalog/catalog.service.ts index 546ec00..5273665 100644 --- a/server/dukang-api/src/modules/catalog/catalog.service.ts +++ b/server/dukang-api/src/modules/catalog/catalog.service.ts @@ -12,7 +12,7 @@ import { mapSkuDto, mapSpecAttrsDto, pickDisplaySku, - resolveOrderSku, + resolveOrderSale, } from './product-sku.util'; export type CatalogViewer = { @@ -204,7 +204,7 @@ export class CatalogService { }); } - /** 下单前校验:白名单商品仅全局测试白名单手机号可买;返回 SPU + 解析后的 SKU */ + /** 下单前校验:白名单商品仅全局测试白名单手机号可买;无 SKU 时回落 SPU 字段 */ async assertPurchasable( productId: bigint, viewerPhone?: string | null, @@ -224,8 +224,8 @@ export class CatalogService { throw new BadRequestException('该商品暂不对当前账号开放'); } } - const sku = resolveOrderSku(product.skus, skuId); - return { product, sku }; + const sale = resolveOrderSale(product, product.skus ?? [], skuId); + return { product, sale }; } async resolveUserPhone(userId: bigint): Promise { diff --git a/server/dukang-api/src/modules/catalog/product-sku.util.ts b/server/dukang-api/src/modules/catalog/product-sku.util.ts index 6377f30..520bc60 100644 --- a/server/dukang-api/src/modules/catalog/product-sku.util.ts +++ b/server/dukang-api/src/modules/catalog/product-sku.util.ts @@ -37,23 +37,79 @@ export function buildSpecText( .join(' / '); } +/** 下单用销售快照:有可售 SKU 则用 SKU,否则回落 SPU(旧客户端 / 未回填) */ +export type OrderSaleSnapshot = { + skuId: bigint | null; + skuCode: string; + barcode69: string; + specText: string; + price: CommonProductSku['price']; + benefitAmount: CommonProductSku['benefitAmount']; + allowOnSitePickup: boolean; + allowOnlinePurchase: boolean; + allowCrossCityDelivery: boolean; + saleUnit: ProductSaleUnit; + bottlesPerUnit: number; +}; + +export function saleSnapshotFromProduct(product: CommonProductItem): OrderSaleSnapshot { + return { + skuId: null, + skuCode: product.skuCode, + barcode69: product.barcode69, + specText: product.spec, + price: product.price, + benefitAmount: product.benefitAmount, + allowOnSitePickup: product.allowOnSitePickup, + allowOnlinePurchase: product.allowOnlinePurchase, + allowCrossCityDelivery: product.allowCrossCityDelivery, + saleUnit: 'BOTTLE', + bottlesPerUnit: 1, + }; +} + +export function saleSnapshotFromSku( + sku: CommonProductSku, + product: CommonProductItem, +): OrderSaleSnapshot { + return { + skuId: sku.id, + skuCode: sku.skuCode, + barcode69: sku.barcode69, + specText: sku.specText || product.spec, + price: sku.price, + benefitAmount: sku.benefitAmount, + allowOnSitePickup: sku.allowOnSitePickup, + allowOnlinePurchase: sku.allowOnlinePurchase, + allowCrossCityDelivery: sku.allowCrossCityDelivery, + saleUnit: sku.saleUnit, + bottlesPerUnit: sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : sku.saleUnit === 'BOX' ? BOTTLES_PER_BOX : 1, + }; +} + /** - * 解析下单 SKU:显式 skuId,或单可售自动回落;多可售且未传则 400。 + * 现网兼容: + * - 不传 skuId:有可售 SKU 用默认/唯一可售;一个都没有则回落 SPU 字段(未回填也能下单) + * - 传入 skuId:按 SKU 校验(新客户端选规格) + * 不在旧接口上因「无 SKU」或「多规格未选」打断现网下单。 */ -export function resolveOrderSku( +export function resolveOrderSale( + product: CommonProductItem, skus: CommonProductSku[], skuId?: string | null, -): CommonProductSku { - const onSale = skus.filter(isSkuOnSale); +): OrderSaleSnapshot { if (skuId) { const found = skus.find((s) => s.id.toString() === String(skuId)); if (!found) throw new BadRequestException('规格不存在'); if (!isSkuOnSale(found)) throw new BadRequestException('该规格暂不可购买'); - return found; + return saleSnapshotFromSku(found, product); } - if (onSale.length === 1) return onSale[0]; - if (onSale.length === 0) throw new BadRequestException('商品暂无可售规格'); - throw new BadRequestException('请选择规格'); + const onSale = skus.filter(isSkuOnSale); + if (onSale.length >= 1) { + const picked = onSale.find((s) => s.isDefault) ?? onSale[0]; + return saleSnapshotFromSku(picked, product); + } + return saleSnapshotFromProduct(product); } /** 列表/拍平:优先默认可售 → 最低价可售 → 默认任意 → 任意 */ diff --git a/server/dukang-api/src/modules/ops/admin-products.service.ts b/server/dukang-api/src/modules/ops/admin-products.service.ts index b3768b1..f3d2add 100644 --- a/server/dukang-api/src/modules/ops/admin-products.service.ts +++ b/server/dukang-api/src/modules/ops/admin-products.service.ts @@ -39,6 +39,11 @@ function normalizePhones(phones?: string[]): string[] { const SKU_AUTO_PREFIX = 'DK'; const SKU_AUTO_PAD = 6; +const SKU_AUTO_RE = /^DK(\d+)$/; + +function isDkSkuCode(code: string | null | undefined): boolean { + return !!code && SKU_AUTO_RE.test(code); +} const MAX_SPEC_ATTRS = 3; const MAX_SPEC_VALUES = 10; @@ -465,14 +470,31 @@ export class AdminProductsService { throw new BadRequestException('请且仅指定一个默认 SKU'); } - const barcodes = rows.map((r) => r.barcode69.trim()); + const barcodes = rows.map((r) => r.barcode69.trim()).filter(Boolean); + if (barcodes.length !== rows.length) { + throw new BadRequestException('每个规格须填写 69 码'); + } if (new Set(barcodes).size !== barcodes.length) { - throw new BadRequestException('69 码不可重复'); + throw new BadRequestException('同一商品内 69 码不可重复,每个规格须使用不同 69 码'); + } + const barcodeTaken = await this.prisma.commonProductSku.findFirst({ + where: { barcode69: { in: barcodes }, productId: { not: productId } }, + select: { barcode69: true }, + }); + if (barcodeTaken) { + throw new BadRequestException(`69 码已存在:${barcodeTaken.barcode69}`); } await this.prisma.$transaction(async (tx) => { const existing = await tx.commonProductSku.findMany({ where: { productId } }); const keepIds = new Set(rows.map((r) => r.id).filter(Boolean) as string[]); + const needGenerate = rows.filter((row) => { + if (!row.id) return true; + const old = existing.find((s) => s.id.toString() === row.id); + return !old || !isDkSkuCode(old.skuCode); + }).length; + const generatedCodes = await this.allocateAutoSkuCodesTx(tx, needGenerate); + let genIdx = 0; for (const old of existing) { if (keepIds.has(old.id.toString())) continue; @@ -508,7 +530,12 @@ export class AdminProductsService { ? BOTTLES_PER_BOX : 1; const status = (row.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE'; - const skuCode = row.skuCode?.trim() || (await this.nextAutoSkuCodeTx(tx)); + const old = row.id ? existing.find((s) => s.id.toString() === row.id) : undefined; + const skuCode = + old && isDkSkuCode(old.skuCode) ? old.skuCode : generatedCodes[genIdx++]; + if (!skuCode) { + throw new BadRequestException('SKU 码生成失败,请重试'); + } let skuId: bigint; if (row.id) { @@ -609,7 +636,7 @@ export class AdminProductsService { await this.prisma.commonProductSku.create({ data: { productId, - skuCode: product.skuCode, + skuCode: await this.nextAutoSkuCode(), barcode69: product.barcode69, specKey: '', specText: product.spec, @@ -635,7 +662,6 @@ export class AdminProductsService { await this.prisma.commonProductSku.update({ where: { id: defaultSku.id }, data: { - skuCode: product.skuCode, barcode69: product.barcode69, specText: product.spec, price: product.price, @@ -651,12 +677,22 @@ export class AdminProductsService { /** 生成 DK + 6 位自增 SKU,冲突重试 */ private async nextAutoSkuCode(): Promise { - return this.nextAutoSkuCodeTx(this.prisma); + const [code] = await this.allocateAutoSkuCodesTx(this.prisma, 1); + return code; } private async nextAutoSkuCodeTx( db: Prisma.TransactionClient | PrismaService, ): Promise { + const [code] = await this.allocateAutoSkuCodesTx(db, 1); + return code; + } + + private async allocateAutoSkuCodesTx( + db: Prisma.TransactionClient | PrismaService, + count: number, + ): Promise { + if (count <= 0) return []; const [fromItem, fromSku] = await Promise.all([ db.commonProductItem.findMany({ where: { skuCode: { startsWith: SKU_AUTO_PREFIX } }, @@ -668,14 +704,15 @@ export class AdminProductsService { }), ]); let maxSeq = 0; - const re = new RegExp(`^${SKU_AUTO_PREFIX}(\\d+)$`); for (const row of [...fromItem, ...fromSku]) { - const m = re.exec(row.skuCode); + const m = SKU_AUTO_RE.exec(row.skuCode); if (!m) continue; const n = Number(m[1]); if (Number.isFinite(n) && n > maxSeq) maxSeq = n; } - return `${SKU_AUTO_PREFIX}${String(maxSeq + 1).padStart(SKU_AUTO_PAD, '0')}`; + return Array.from({ length: count }, (_, i) => { + return `${SKU_AUTO_PREFIX}${String(maxSeq + 1 + i).padStart(SKU_AUTO_PAD, '0')}`; + }); } private async createWithGeneratedSku( diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 37daa57..fdea4d1 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -1353,6 +1353,7 @@ class AdminSkuRowDto { @IsString({ each: true }) specValueIds?: string[]; + /** 忽略;服务端自动生成 DK 码 */ @IsOptional() @IsString() skuCode?: string; diff --git a/server/dukang-api/src/modules/trade/dto/after-sale.dto.ts b/server/dukang-api/src/modules/trade/dto/after-sale.dto.ts index b672406..c0404f2 100644 --- a/server/dukang-api/src/modules/trade/dto/after-sale.dto.ts +++ b/server/dukang-api/src/modules/trade/dto/after-sale.dto.ts @@ -53,6 +53,12 @@ export class CreateInvoiceDto { @IsIn(['NORMAL', 'SPECIAL']) invoiceKind?: string; + /** 酒水类 / 餐饮类;缺省酒水类。C 端票种固定普通发票 */ + @IsOptional() + @IsString() + @IsIn(['LIQUOR', 'CATERING']) + invoiceCategory?: string; + @ValidateIf((o: CreateInvoiceDto) => !o.titleId) @IsString() @IsNotEmpty() diff --git a/server/dukang-api/src/modules/trade/invoice-title.service.ts b/server/dukang-api/src/modules/trade/invoice-title.service.ts index b3a9fbc..3a5726d 100644 --- a/server/dukang-api/src/modules/trade/invoice-title.service.ts +++ b/server/dukang-api/src/modules/trade/invoice-title.service.ts @@ -40,7 +40,7 @@ export class InvoiceTitleService { titleType: body.titleType, titleName: body.titleName.trim(), taxNo: body.taxNo?.trim() || null, - email: body.email?.trim() || null, + email: body.email!.trim(), phone: body.phone?.trim() || null, addressPhone: body.addressPhone?.trim() || null, bankAccount: body.bankAccount?.trim() || null, @@ -73,7 +73,7 @@ export class InvoiceTitleService { titleType: body.titleType, titleName: body.titleName.trim(), taxNo: body.taxNo?.trim() || null, - email: body.email?.trim() || null, + email: body.email!.trim(), phone: body.phone?.trim() || null, addressPhone: body.addressPhone?.trim() || null, bankAccount: body.bankAccount?.trim() || null, @@ -101,6 +101,13 @@ export class InvoiceTitleService { if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) { throw new BadRequestException('企业抬头须填写税号'); } + const email = body.email?.trim() || ''; + if (!email) { + throw new BadRequestException('请填写接收邮箱'); + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new BadRequestException('邮箱格式不正确'); + } if (isUpdate && body.titleType === undefined) { throw new BadRequestException('titleType 必填'); } diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index e52edd1..e91ee31 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -38,7 +38,7 @@ import { AlertService } from '../../common/alert/alert.service'; import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import type { Request } from 'express'; -import type { CommonProductSku } from '@prisma/client'; +import type { ProductSaleUnit } from '@prisma/client'; @Injectable() export class TradeService { @@ -64,23 +64,34 @@ export class TradeService { private readonly logger = new Logger(TradeService.name); - private overlayProductWithSku( + private overlayProductWithSale( productDto: Record, - sku: CommonProductSku, + sale: { + skuId: bigint | null; + skuCode: string; + specText: string; + price: unknown; + benefitAmount: unknown; + allowOnSitePickup: boolean; + allowOnlinePurchase: boolean; + allowCrossCityDelivery: boolean; + saleUnit: ProductSaleUnit; + bottlesPerUnit: number; + }, ): Record { return { ...productDto, - skuCode: sku.skuCode, - spec: sku.specText || productDto.spec, - price: Number(sku.price), - benefitAmount: Number(sku.benefitAmount ?? sku.price), - benefitDisplay: Number(sku.benefitAmount ?? sku.price), - allowOnSitePickup: sku.allowOnSitePickup, - allowOnlinePurchase: sku.allowOnlinePurchase, - allowCrossCityDelivery: sku.allowCrossCityDelivery, - saleUnit: sku.saleUnit, - bottlesPerUnit: sku.bottlesPerUnit, - selectedSkuId: sku.id.toString(), + skuCode: sale.skuCode, + spec: sale.specText || productDto.spec, + price: Number(sale.price), + benefitAmount: Number(sale.benefitAmount ?? sale.price), + benefitDisplay: Number(sale.benefitAmount ?? sale.price), + allowOnSitePickup: sale.allowOnSitePickup, + allowOnlinePurchase: sale.allowOnlinePurchase, + allowCrossCityDelivery: sale.allowCrossCityDelivery, + saleUnit: sale.saleUnit, + bottlesPerUnit: sale.bottlesPerUnit, + ...(sale.skuId ? { selectedSkuId: sale.skuId.toString() } : {}), }; } @@ -95,7 +106,7 @@ export class TradeService { }, ) { const viewerPhone = await this.catalogService.resolveUserPhone(userId); - const { product: spu, sku } = await this.catalogService.assertPurchasable( + const { product: spu, sale } = await this.catalogService.assertPurchasable( BigInt(body.productId), viewerPhone, body.skuId, @@ -104,13 +115,13 @@ export class TradeService { if (!productDto || productDto.status !== 'ON_SALE') { throw new BadRequestException('商品不可购买'); } - const product = this.overlayProductWithSku(productDto as Record, sku); + const product = this.overlayProductWithSale(productDto as Record, sale); const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } }); if (!city) throw new BadRequestException('暂无开城城市'); const onSitePickup = !!body.onSitePickup; - if (onSitePickup && !sku.allowOnSitePickup) { + if (onSitePickup && !sale.allowOnSitePickup) { throw new BadRequestException('该规格不支持现场取货'); } @@ -129,8 +140,8 @@ export class TradeService { let addressOk = true; let addressMessage: string | null = null; if (!onSitePickup) { - const allowOnline = sku.allowOnlinePurchase !== false; - const allowCross = sku.allowCrossCityDelivery !== false; + const allowOnline = sale.allowOnlinePurchase !== false; + const allowCross = sale.allowCrossCityDelivery !== false; if (deliveryType === 'LOCAL' && !allowOnline) { addressOk = false; addressMessage = '该规格不支持线上购买'; @@ -145,20 +156,20 @@ export class TradeService { } } - const bottlesPerUnit = sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : 1; + const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1; const check = validateMinPurchase( deliveryType, body.quantity, city.localMinQty, city.crossMinQty, - { bottlesPerUnit, saleUnit: sku.saleUnit }, + { bottlesPerUnit, saleUnit: sale.saleUnit }, ); - const unitPrice = Number(sku.price); + const unitPrice = Number(sale.price); const productAmount = unitPrice * body.quantity; const benefitPerUnit = calcBenefitAmount({ price: unitPrice, - benefitAmount: sku.benefitAmount != null ? Number(sku.benefitAmount) : null, + benefitAmount: sale.benefitAmount != null ? Number(sale.benefitAmount) : null, }); const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null; @@ -186,10 +197,13 @@ export class TradeService { addressMessage, minQty, onSitePickup, - allowCrossCityDelivery: sku.allowCrossCityDelivery !== false, - allowOnlinePurchase: sku.allowOnlinePurchase !== false, - skuId: sku.id.toString(), - saleUnit: sku.saleUnit, + allowCrossCityDelivery: sale.allowCrossCityDelivery !== false, + allowOnlinePurchase: sale.allowOnlinePurchase !== false, + skuId: sale.skuId?.toString(), + barcode69: sale.barcode69, + productSpec: sale.specText, + unitPrice, + saleUnit: sale.saleUnit, bottlesPerUnit, bottleQuantity: body.quantity * bottlesPerUnit, }; @@ -247,9 +261,6 @@ export class TradeService { const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: BigInt(body.productId) }, }); - const sku = await this.prisma.commonProductSku.findUniqueOrThrow({ - where: { id: BigInt(preview.skuId) }, - }); const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); const orderNo = generateOrderNo(); const payExpireAt = new Date(Date.now() + 30 * 60 * 1000); @@ -282,15 +293,15 @@ export class TradeService { payStatus: 'UNPAID', deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP', productId: product.id, - skuId: sku.id, - barcode69: sku.barcode69, + skuId: preview.skuId ? BigInt(preview.skuId) : null, + barcode69: preview.barcode69 || product.barcode69, productName: product.name, - productSpec: sku.specText || product.spec, + productSpec: preview.productSpec || product.spec, imageResourceId: product.coverResourceId, quantity: body.quantity, - saleUnit: sku.saleUnit, + saleUnit: preview.saleUnit, bottlesPerUnit: preview.bottlesPerUnit, - listUnitPrice: sku.price, + listUnitPrice: preview.unitPrice, listAmount: preview.productAmount, productAmount: preview.productAmount, receiverName, @@ -346,7 +357,7 @@ export class TradeService { extraJson: { orderId: order.id.toString(), productId: body.productId, - skuId: sku.id.toString(), + skuId: preview.skuId, quantity: body.quantity, onSitePickup, }, @@ -1080,6 +1091,7 @@ export class TradeService { titleId?: string; titleType?: string; invoiceKind?: string; + invoiceCategory?: string; titleName?: string; taxNo?: string | null; addressPhone?: string | null; @@ -1088,6 +1100,7 @@ export class TradeService { phone?: string; remark?: string; }, + opts?: { allowSpecialKind?: boolean }, ) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); if (!order) throw new NotFoundException('订单不存在'); @@ -1137,7 +1150,8 @@ export class TradeService { if (resolved.titleType === 'ENTERPRISE' && !resolved.taxNo?.trim()) { throw new BadRequestException('企业抬头须填写税号'); } - const invoiceKind = body.invoiceKind || 'NORMAL'; + const invoiceKind = + opts?.allowSpecialKind && body.invoiceKind === 'SPECIAL' ? 'SPECIAL' : 'NORMAL'; if (invoiceKind === 'SPECIAL') { if (resolved.titleType !== 'ENTERPRISE') { throw new BadRequestException('专用发票仅支持企业抬头'); @@ -1146,6 +1160,7 @@ export class TradeService { throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号'); } } + const invoiceCategory = body.invoiceCategory === 'CATERING' ? 'CATERING' : 'LIQUOR'; const invoice = await this.prisma.userInvoice.create({ data: { @@ -1154,6 +1169,7 @@ export class TradeService { userId, titleType: resolved.titleType as never, invoiceKind: invoiceKind as never, + invoiceCategory: invoiceCategory as never, titleName: resolved.titleName.trim(), taxNo: resolved.taxNo?.trim() || null, addressPhone: resolved.addressPhone?.trim() || null, @@ -1161,7 +1177,7 @@ export class TradeService { email: resolved.email.trim(), phone: resolved.phone.trim(), remark: body.remark?.trim() || null, - }, + } as never, }); if (!order.isTest) { @@ -1254,6 +1270,7 @@ export class TradeService { titleId?: string; titleType?: string; invoiceKind?: string; + invoiceCategory?: string; titleName?: string; taxNo?: string; addressPhone?: string; @@ -1267,7 +1284,7 @@ export class TradeService { if (!orderNo) throw new BadRequestException('请填写订单号'); const order = await this.prisma.order.findFirst({ where: { orderNo } }); if (!order) throw new NotFoundException('订单不存在'); - return this.createInvoice(order.userId, order.id, body); + return this.createInvoice(order.userId, order.id, body, { allowSpecialKind: true }); } async adminListInvoices(query: { @@ -1598,7 +1615,7 @@ export class TradeService { }, viewer?: { phone?: string | null; bypassWhitelist?: boolean }, ) { - const { product: spu, sku } = await this.catalogService.assertPurchasable( + const { product: spu, sale } = await this.catalogService.assertPurchasable( BigInt(body.productId), viewer?.phone, body.skuId, @@ -1620,7 +1637,7 @@ export class TradeService { let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL'; if (deliveryMode === 'ON_SITE_PICKUP') { - if (!sku.allowOnSitePickup) { + if (!sale.allowOnSitePickup) { throw new BadRequestException('该规格不支持现场提货'); } deliveryType = 'ON_SITE_PICKUP'; @@ -1629,8 +1646,8 @@ export class TradeService { if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') { deliveryType = 'CROSS_CITY'; } - const allowOnline = sku.allowOnlinePurchase !== false; - const allowCross = sku.allowCrossCityDelivery !== false; + const allowOnline = sale.allowOnlinePurchase !== false; + const allowCross = sale.allowCrossCityDelivery !== false; if (deliveryType === 'LOCAL' && !allowOnline) { throw new BadRequestException('该规格不支持线上购买'); } @@ -1644,21 +1661,21 @@ export class TradeService { } } - const bottlesPerUnit = sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : 1; + const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1; const check = validateMinPurchase( deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : deliveryType === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'LOCAL', body.quantity, city.localMinQty, city.crossMinQty, - { bottlesPerUnit, saleUnit: sku.saleUnit }, + { bottlesPerUnit, saleUnit: sale.saleUnit }, ); if (!check.ok) throw new BadRequestException(check.message); - const unitPrice = Number(sku.price); + const unitPrice = Number(sale.price); const productAmount = unitPrice * body.quantity; const benefitPerUnit = calcBenefitAmount({ price: unitPrice, - benefitAmount: sku.benefitAmount != null ? Number(sku.benefitAmount) : null, + benefitAmount: sale.benefitAmount != null ? Number(sale.benefitAmount) : null, }); return { @@ -1667,8 +1684,10 @@ export class TradeService { benefitAmount: benefitPerUnit * body.quantity, deliveryType, unitPrice, - skuId: sku.id.toString(), - saleUnit: sku.saleUnit, + skuId: sale.skuId?.toString(), + barcode69: sale.barcode69, + productSpec: sale.specText, + saleUnit: sale.saleUnit, bottlesPerUnit, bottleQuantity: body.quantity * bottlesPerUnit, minQuantity: toMinSaleQuantity( @@ -1755,9 +1774,6 @@ export class TradeService { const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: BigInt(body.productId) }, }); - const sku = await this.prisma.commonProductSku.findUniqueOrThrow({ - where: { id: BigInt(preview.skuId) }, - }); const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`; @@ -1815,15 +1831,15 @@ export class TradeService { channelSource: 'PROXY_ONLINE', promoCodeId, productId: product.id, - skuId: sku.id, - barcode69: sku.barcode69, + skuId: preview.skuId ? BigInt(preview.skuId) : null, + barcode69: preview.barcode69 || product.barcode69, productName: product.name, - productSpec: sku.specText || product.spec, + productSpec: preview.productSpec || product.spec, imageResourceId: product.coverResourceId, quantity: body.quantity, - saleUnit: sku.saleUnit, + saleUnit: preview.saleUnit, bottlesPerUnit: preview.bottlesPerUnit, - listUnitPrice: sku.price, + listUnitPrice: preview.unitPrice, listAmount: preview.productAmount, productAmount: preview.productAmount, payAmount: preview.payAmount, @@ -2171,9 +2187,6 @@ export class TradeService { const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: BigInt(body.productId) }, }); - const sku = await this.prisma.commonProductSku.findUniqueOrThrow({ - where: { id: BigInt(preview.skuId) }, - }); const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`; @@ -2231,15 +2244,15 @@ export class TradeService { channelSource: 'PROXY_ONLINE', promoCodeId, productId: product.id, - skuId: sku.id, - barcode69: sku.barcode69, + skuId: preview.skuId ? BigInt(preview.skuId) : null, + barcode69: preview.barcode69 || product.barcode69, productName: product.name, - productSpec: sku.specText || product.spec, + productSpec: preview.productSpec || product.spec, imageResourceId: product.coverResourceId, quantity: body.quantity, - saleUnit: sku.saleUnit, + saleUnit: preview.saleUnit, bottlesPerUnit: preview.bottlesPerUnit, - listUnitPrice: sku.price, + listUnitPrice: preview.unitPrice, listAmount: preview.productAmount, productAmount: preview.productAmount, payAmount: preview.payAmount,