v4.0.18版本更新
This commit is contained in:
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
|
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
|
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
|
||||||
import { request, type HqProfile, type Paginated } from '../lib/api';
|
import { request, requestDownload, type HqProfile, type Paginated } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import ActivityPosterDownloadModal from './ActivityPosterDownloadModal';
|
import ActivityPosterDownloadModal from './ActivityPosterDownloadModal';
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
|
|||||||
const [canEditAssoc, setCanEditAssoc] = useState(false);
|
const [canEditAssoc, setCanEditAssoc] = useState(false);
|
||||||
const [canDownloadPosters, setCanDownloadPosters] = useState(false);
|
const [canDownloadPosters, setCanDownloadPosters] = useState(false);
|
||||||
const [posterDownloadOpen, setPosterDownloadOpen] = useState(false);
|
const [posterDownloadOpen, setPosterDownloadOpen] = useState(false);
|
||||||
|
const [downloadingQr, setDownloadingQr] = useState(false);
|
||||||
|
|
||||||
const loadSummary = useCallback(async () => {
|
const loadSummary = useCallback(async () => {
|
||||||
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
|
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
|
||||||
@@ -78,6 +79,26 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadBareQrcode() {
|
||||||
|
if (!summary?.qrcodeUrl) {
|
||||||
|
message.warning('尚未生成关联码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDownloadingQr(true);
|
||||||
|
try {
|
||||||
|
await requestDownload(
|
||||||
|
`/admin/partners/${partnerId}/assoc/qrcode`,
|
||||||
|
{},
|
||||||
|
`partner-assoc-${partnerId}.png`,
|
||||||
|
);
|
||||||
|
message.success('已开始下载二维码');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '下载二维码失败');
|
||||||
|
} finally {
|
||||||
|
setDownloadingQr(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function unbind(userId: string) {
|
async function unbind(userId: string) {
|
||||||
try {
|
try {
|
||||||
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
|
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
|
||||||
@@ -137,12 +158,19 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
|
|||||||
关联用户 {summary?.userCount ?? 0} 人
|
关联用户 {summary?.userCount ?? 0} 人
|
||||||
</Link>
|
</Link>
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<Button loading={issuing} onClick={() => void reissue()}>
|
<Space wrap>
|
||||||
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
|
<Button loading={issuing} onClick={() => void reissue()}>
|
||||||
</Button>
|
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
|
||||||
{canDownloadPosters ? (
|
</Button>
|
||||||
<Button onClick={() => setPosterDownloadOpen(true)}>下载活动图</Button>
|
{summary?.qrcodeUrl ? (
|
||||||
) : null}
|
<Button loading={downloadingQr} onClick={() => void downloadBareQrcode()}>
|
||||||
|
下载二维码
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canDownloadPosters ? (
|
||||||
|
<Button onClick={() => setPosterDownloadOpen(true)}>下载活动图</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -175,3 +175,12 @@ export function storeLeafCategoryIds(store: {
|
|||||||
const id = String(store.categoryId || store.category?.id || '');
|
const id = String(store.categoryId || store.category?.id || '');
|
||||||
return id ? [id] : [];
|
return id ? [id] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** C 端是否渲染「核销N次」;开关关闭或次数为 0 时不展示 */
|
||||||
|
export function shouldShowStoreRedeemCount(
|
||||||
|
enabled: boolean | undefined,
|
||||||
|
count?: number | null,
|
||||||
|
): boolean {
|
||||||
|
if (enabled === false) return false;
|
||||||
|
return Number(count) > 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../component
|
|||||||
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import { request, toast, isLoggedIn } from '../../lib/api';
|
import { request, toast, isLoggedIn } from '../../lib/api';
|
||||||
|
import { fetchClientConfig } from '../../lib/pay-wechat';
|
||||||
import { toMoneyNumber } from '../../lib/money';
|
import { toMoneyNumber } from '../../lib/money';
|
||||||
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
||||||
import { track } from '../../lib/analytics';
|
import { track } from '../../lib/analytics';
|
||||||
import {
|
import {
|
||||||
fullStoreAddress,
|
fullStoreAddress,
|
||||||
|
shouldShowStoreRedeemCount,
|
||||||
storeCategoryTags,
|
storeCategoryTags,
|
||||||
storeStarCount,
|
storeStarCount,
|
||||||
type StoreCategoryTreeNode,
|
type StoreCategoryTreeNode,
|
||||||
@@ -190,6 +192,7 @@ export default function StoreDetailPage() {
|
|||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
const [pendingRatingId, setPendingRatingId] = useState<string | null>(null);
|
const [pendingRatingId, setPendingRatingId] = useState<string | null>(null);
|
||||||
|
const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true);
|
||||||
const storeRef = useRef<Store | null>(null);
|
const storeRef = useRef<Store | null>(null);
|
||||||
storeRef.current = store;
|
storeRef.current = store;
|
||||||
|
|
||||||
@@ -197,6 +200,12 @@ export default function StoreDetailPage() {
|
|||||||
setHeaderSolid(scrollTop > 100);
|
setHeaderSolid(scrollTop > 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchClientConfig()
|
||||||
|
.then((cfg) => setShowStoreRedeemCount(cfg.showStoreRedeemCount !== false))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadStore = useCallback(async (id: string) => {
|
const loadStore = useCallback(async (id: string) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -440,7 +449,7 @@ export default function StoreDetailPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
{Number(store.redeemCount) > 0 ? (
|
{shouldShowStoreRedeemCount(showStoreRedeemCount, store.redeemCount) ? (
|
||||||
<Text className="store-detail-redeem">核销{store.redeemCount}次</Text>
|
<Text className="store-detail-redeem">核销{store.redeemCount}次</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
||||||
import { formatDistanceMeters } from '../../lib/geo';
|
import { formatDistanceMeters } from '../../lib/geo';
|
||||||
import { getToken, request, toast } from '../../lib/api';
|
import { getToken, request, toast } from '../../lib/api';
|
||||||
|
import { fetchClientConfig } from '../../lib/pay-wechat';
|
||||||
import {
|
import {
|
||||||
getStoresListCache,
|
getStoresListCache,
|
||||||
isStoresSessionBootstrapped,
|
isStoresSessionBootstrapped,
|
||||||
@@ -42,7 +43,7 @@ import {
|
|||||||
toWeappShareTimeline,
|
toWeappShareTimeline,
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
||||||
import { fullStoreAddress, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display';
|
import { fullStoreAddress, shouldShowStoreRedeemCount, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display';
|
||||||
import openBadgeImg from '../../assets/icons/store-open-badge.png';
|
import openBadgeImg from '../../assets/icons/store-open-badge.png';
|
||||||
|
|
||||||
type Store = {
|
type Store = {
|
||||||
@@ -123,13 +124,21 @@ export default function StoresPage() {
|
|||||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||||
const [sort, setSort] = useState<StoreSortKey>(() => cached?.sort ?? 'nearby');
|
const [sort, setSort] = useState<StoreSortKey>(() => cached?.sort ?? 'nearby');
|
||||||
const [sortOpen, setSortOpen] = useState(false);
|
const [sortOpen, setSortOpen] = useState(false);
|
||||||
|
const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true);
|
||||||
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
|
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
|
||||||
const fetchSeqRef = useRef(0);
|
const fetchSeqRef = useRef(0);
|
||||||
const regionRef = useRef(region);
|
const regionRef = useRef(region);
|
||||||
regionRef.current = region;
|
regionRef.current = region;
|
||||||
const regionLabel = formatRegionLabel(region);
|
const regionLabel = formatRegionLabel(region);
|
||||||
const categoryLabel = formatCategoryLabel(category);
|
const categoryLabel = formatCategoryLabel(category);
|
||||||
const sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先';
|
const sortOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
showStoreRedeemCount
|
||||||
|
? STORE_SORT_OPTIONS
|
||||||
|
: STORE_SORT_OPTIONS.filter((o) => o.key !== 'redeem'),
|
||||||
|
[showStoreRedeemCount],
|
||||||
|
);
|
||||||
|
const sortLabel = sortOptions.find((o) => o.key === sort)?.label ?? '附近优先';
|
||||||
const showBootLoading = loading && stores.length === 0;
|
const showBootLoading = loading && stores.length === 0;
|
||||||
|
|
||||||
const childIdsByParent = useMemo(() => {
|
const childIdsByParent = useMemo(() => {
|
||||||
@@ -143,6 +152,22 @@ export default function StoresPage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [categoryTree]);
|
}, [categoryTree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchClientConfig()
|
||||||
|
.then((cfg) => {
|
||||||
|
const enabled = cfg.showStoreRedeemCount !== false;
|
||||||
|
setShowStoreRedeemCount(enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
setSort((prev) => {
|
||||||
|
if (prev !== 'redeem') return prev;
|
||||||
|
patchStoresFilterCache({ sort: 'nearby' });
|
||||||
|
return 'nearby';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function fetchStores(
|
async function fetchStores(
|
||||||
nextCode: string,
|
nextCode: string,
|
||||||
coords: UserCoords | null,
|
coords: UserCoords | null,
|
||||||
@@ -329,7 +354,7 @@ export default function StoresPage() {
|
|||||||
if (sort === 'rating') {
|
if (sort === 'rating') {
|
||||||
const diff = storeStarCount(b.rating) - storeStarCount(a.rating);
|
const diff = storeStarCount(b.rating) - storeStarCount(a.rating);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
} else if (sort === 'redeem') {
|
} else if (sort === 'redeem' && showStoreRedeemCount) {
|
||||||
const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0);
|
const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
}
|
}
|
||||||
@@ -338,7 +363,7 @@ export default function StoresPage() {
|
|||||||
return da - db;
|
return da - db;
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
}, [stores, region, category, keyword, sort, childIdsByParent]);
|
}, [stores, region, category, keyword, sort, childIdsByParent, showStoreRedeemCount]);
|
||||||
|
|
||||||
function applySearch() {
|
function applySearch() {
|
||||||
const next = keywordInput.trim();
|
const next = keywordInput.trim();
|
||||||
@@ -497,7 +522,7 @@ export default function StoresPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
{Number(s.redeemCount) > 0 ? (
|
{shouldShowStoreRedeemCount(showStoreRedeemCount, s.redeemCount) ? (
|
||||||
<Text className="store-card-redeem">核销{s.redeemCount}次</Text>
|
<Text className="store-card-redeem">核销{s.redeemCount}次</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -551,7 +576,7 @@ export default function StoresPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="region-picker-list">
|
<View className="region-picker-list">
|
||||||
{STORE_SORT_OPTIONS.map((opt) => (
|
{sortOptions.map((opt) => (
|
||||||
<View
|
<View
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
className={`region-picker-option${sort === opt.key ? ' selected' : ''}`}
|
className={`region-picker-option${sort === opt.key ? ' selected' : ''}`}
|
||||||
|
|||||||
@@ -152,6 +152,7 @@
|
|||||||
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
||||||
- HQ 主列表:最左序号;「列设置」控制显示列与顺序;可拖表头分割线改列宽;主展示列下划线点击进编辑/详情;偏好挂当前 HQ 账号。
|
- HQ 主列表:最左序号;「列设置」控制显示列与顺序;可拖表头分割线改列宽;主展示列下划线点击进编辑/详情;偏好挂当前 HQ 账号。
|
||||||
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
||||||
|
- C 端小程序门店列表/详情的「核销N次」由 HQ「系统设置 → 功能开关 → C 端展示门店核销次数」(`SHOW_STORE_REDEEM_COUNT`)控制,默认开;关闭后接口不再返回次数。
|
||||||
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
||||||
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -58,7 +58,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**起购(v3.5.11)**:现场提货 / 同城 / 跨城阈值在城市 `pickup_min_qty` / `local_min_qty` / `cross_min_qty`(瓶当量)。承运商「起送瓶数」只用于物流计价,不拦下单。
|
**起购(v3.5.11)**:现场提货 / 同城 / 跨城阈值在城市 `pickup_min_qty` / `local_min_qty` / `cross_min_qty`(瓶当量)。承运商「起送瓶数」只用于物流计价,不拦下单。
|
||||||
|
|
||||||
**订单大屏(v3.5.12)**:循环 BGM;右上角「播放 BGM」开关(需点击才出声);成交撒花时压低背景音。HQ 日志 / 订单状态流转 / 用户行为时间线展示中文(存储码不变)。删除门店分类后列表不再自动补种默认树(「同步默认分类」才补)。HQ 侧栏:概览→用户→商品→订单→开城→门店→财务→好客权益→配送单→工单→发票,其余运营/工具项随后,**系统设置最后**。
|
**订单大屏(v3.5.12)**:循环 BGM;右上角「播放 BGM」开关(需点击才出声);成交撒花时压低背景音。HQ 日志 / 订单状态流转 / 用户行为时间线展示中文(存储码不变)。删除门店分类后列表不再自动补种默认树(「同步默认分类」才补)。HQ 侧栏:概览→用户→商品→订单→开城→门店→财务→好客权益→配送单→工单→发票,其余运营/工具项随后,**系统设置最后**。C 端门店「核销N次」由系统设置功能开关 `SHOW_STORE_REDEEM_COUNT` 控制(默认开)。
|
||||||
|
|
||||||
**用户日志端(v3.5.14)**:`order_submit` / `pay_success` 的 `clientApp` 取 JWT(小程序 `USER_MINI`);微信支付回调沿用该订单已有埋点,缺省小程序。禁止再写死 `USER_H5`。
|
**用户日志端(v3.5.14)**:`order_submit` / `pay_success` 的 `clientApp` 取 JWT(小程序 `USER_MINI`);微信支付回调沿用该订单已有埋点,缺省小程序。禁止再写死 `USER_H5`。
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**活动图(v4.0.2 / v4.0.7)**:规则见 v4-PRD §6。表 `activity_poster`;HQ `GET/POST/PUT/DELETE /admin/activity-posters`(权限 `activity_posters`);合伙人 `GET /partner/activity-posters` · `GET/PUT /partner/activity-posters/selection`(写入 `partner_account.activity_poster_id`)· `GET /partner/activity-posters/:id/image`(合成本人关联码)。`GET /partner/assoc` 返回 `activityPosterId`(**子账号强制为空**)。码栏百分比相对图宽。子账号可进用户管理、下载纯关联码,无活动图入口。v4.0.7:HQ `GET /admin/activity-posters/:id/image?partnerId=` 单张合成 PNG;`POST /admin/activity-posters/:id/partner-pack` `{ partnerIds }` 流式 zip(仅勾选,测试号/无码 skip)。城市合伙人快链 `/activity-posters?partnerId=`。不预生成缓存图、不批量调微信补码。
|
**活动图(v4.0.2 / v4.0.7)**:规则见 v4-PRD §6。表 `activity_poster`;HQ `GET/POST/PUT/DELETE /admin/activity-posters`(权限 `activity_posters`);合伙人 `GET /partner/activity-posters` · `GET/PUT /partner/activity-posters/selection`(写入 `partner_account.activity_poster_id`)· `GET /partner/activity-posters/:id/image`(合成本人关联码)。`GET /partner/assoc` 返回 `activityPosterId`(**子账号强制为空**)。码栏百分比相对图宽。子账号可进用户管理、下载纯关联码,无活动图入口。v4.0.7:HQ `GET /admin/activity-posters/:id/image?partnerId=` 单张合成 PNG;`POST /admin/activity-posters/:id/partner-pack` `{ partnerIds }` 流式 zip(仅勾选,测试号/无码 skip)。城市合伙人快链 `/activity-posters?partnerId=`。不预生成缓存图、不批量调微信补码。
|
||||||
|
|
||||||
**合伙人关联与订单佣金(v4.0.1 / v4.0.9)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `POST /user/partner-assoc/touch`(未登录可计已扫码)· `GET /partner/assoc`(`scanCount` + `userCount`;子账号无 `activityPosterId`)· `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`;主账号与子账号均可)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。子账号创建默认 `ACTIVE`。主账号 `PUT /partner/me/bank` 填收款账户。
|
**合伙人关联与订单佣金(v4.0.1 / v4.0.9)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `POST /user/partner-assoc/touch`(未登录可计已扫码)· `GET /partner/assoc`(`scanCount` + `userCount`;子账号无 `activityPosterId`)· `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`;主账号与子账号均可)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。子账号创建默认 `ACTIVE`。主账号 `PUT /partner/me/bank` 填收款账户。HQ `GET /admin/partners/:id/assoc/qrcode` 下载裸关联码 PNG(与「下载活动图」合成海报分开)。
|
||||||
|
|
||||||
**合伙人周结算(v4.0.9)**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
|
**合伙人周结算(v4.0.9)**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -31,7 +31,7 @@
|
|||||||
- 每个**主合伙人**自动一张微信小程序码(`getwxacodeunlimit`,scene=`pa_{partnerId}`)。不复用推广码。
|
- 每个**主合伙人**自动一张微信小程序码(`getwxacodeunlimit`,scene=`pa_{partnerId}`)。不复用推广码。
|
||||||
- C 端登录后**首次扫码锁定**;已绑定再扫任意码提示「已关联」,不更新。
|
- C 端登录后**首次扫码锁定**;已绑定再扫任意码提示「已关联」,不更新。
|
||||||
- 用户不可自换绑;HQ 持权限 `users_partner_assoc`(修改用户关联合伙人)可解绑或改绑,解绑后可再绑。已支付订单佣金快照不回刷。
|
- 用户不可自换绑;HQ 持权限 `users_partner_assoc`(修改用户关联合伙人)可解绑或改绑,解绑后可再绑。已支付订单佣金快照不回刷。
|
||||||
- 合伙人 H5 可展示并**下载** PNG;微信内下载失败则预览 + 长按保存。
|
- 合伙人 H5 可展示并**下载** PNG;微信内下载失败则预览 + 长按保存。HQ 合伙人账号/开城合伙人详情同样可下载**裸二维码**(「下载二维码」),与「下载活动图」合成海报分开。
|
||||||
- 主合伙人备注写独立表 `partner_user_note`(`partner_account_id` + `user_id` 唯一),与 HQ `user_user.hq_remark` 隔离;换绑后不跟随、不泄漏给下一个合伙人。合伙人 H5 **不返回** `hqRemark`。
|
- 主合伙人备注写独立表 `partner_user_note`(`partner_account_id` + `user_id` 唯一),与 HQ `user_user.hq_remark` 隔离;换绑后不跟随、不泄漏给下一个合伙人。合伙人 H5 **不返回** `hqRemark`。
|
||||||
- 主账号首页两张卡:「关联用户」(按 `assocBoundAt` 拆本日/本月)、「关联用户订单」(已付购酒单且用户**当前**关联本合伙人,按 `paidAt` 拆本日/本月)。文案不用「佣金订单」——与账单快照 `partner_account_id_at_pay` 可能不完全重合。子账号不展示。
|
- 主账号首页两张卡:「关联用户」(按 `assocBoundAt` 拆本日/本月)、「关联用户订单」(已付购酒单且用户**当前**关联本合伙人,按 `paidAt` 拆本日/本月)。文案不用「佣金订单」——与账单快照 `partner_account_id_at_pay` 可能不完全重合。子账号不展示。
|
||||||
- 主账号底部 Tab:首页 / **用户管理** / 门店管理 / 合伙人中心。用户管理页 = 关联码 + 已关联用户列表(搜索昵称/手机/编号/本合伙人备注;排序关联时间/注册时间/订单数)。点订单数看该用户已付购酒单。
|
- 主账号底部 Tab:首页 / **用户管理** / 门店管理 / 合伙人中心。用户管理页 = 关联码 + 已关联用户列表(搜索昵称/手机/编号/本合伙人备注;排序关联时间/注册时间/订单数)。点订单数看该用户已付购酒单。
|
||||||
@@ -90,7 +90,7 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照
|
|||||||
- 微信内下载失败则预览 + 长按保存(与关联码下载一致)。
|
- 微信内下载失败则预览 + 长按保存(与关联码下载一致)。
|
||||||
- 合伙人只看 `ACTIVE`;下架后列表不再出现。无关联码则不可下载并明确报错。
|
- 合伙人只看 `ACTIVE`;下架后列表不再出现。无关联码则不可下载并明确报错。
|
||||||
- HQ 持权限 `activity_posters`(默认超管 + 运营)可增删改、上下架。
|
- HQ 持权限 `activity_posters`(默认超管 + 运营)可增删改、上下架。
|
||||||
- HQ 城市合伙人页提供活动图快链(列表列 / 详情 / 顶栏,进入 `/activity-posters?partnerId=`)。可指定一张已上架活动图:**单个下载**合成 PNG,或 **勾选主合伙人导出 zip**(仅已勾选,不做隐式全量)。合入码为已有 OSS 关联码;无码则单张报错、zip 记入跳过清单。不在本路径批量调微信补码,不预生成每人缓存图。
|
- HQ 城市合伙人页提供活动图快链(列表列 / 详情 / 顶栏,进入 `/activity-posters?partnerId=`)。可指定一张已上架活动图:**单个下载**合成 PNG,或 **勾选主合伙人导出 zip**(仅已勾选,不做隐式全量)。合入码为已有 OSS 关联码;无码则单张报错、zip 记入跳过清单。不在本路径批量调微信补码,不预生成每人缓存图。合伙人详情关联码区另有「下载二维码」,直接下载裸关联码,不贴活动图。
|
||||||
|
|
||||||
## 7. 酒厂对账(v4.0.6)
|
## 7. 酒厂对账(v4.0.6)
|
||||||
|
|
||||||
|
|||||||
@@ -41,3 +41,4 @@
|
|||||||
| 2026-09-07 | v4.0.18:C 端门店列表省市区县地址;门店多收款账户(默认账户打款、切换无需重启);子账号独立继承码 + 子账号维度统计 |
|
| 2026-09-07 | v4.0.18:C 端门店列表省市区县地址;门店多收款账户(默认账户打款、切换无需重启);子账号独立继承码 + 子账号维度统计 |
|
||||||
| 2026-09-08 | v4.0.18 修订:C 端门店地址「省+市+区+详细地址」原样拼接,详细地址已含省市区时不去重 |
|
| 2026-09-08 | v4.0.18 修订:C 端门店地址「省+市+区+详细地址」原样拼接,详细地址已含省市区时不去重 |
|
||||||
| 2026-09-08 | v4.0.18 追加:HQ 财务银行账户总目录(聚合门店/酒厂/合伙人/物流有效账户;可新增不挂门店账户;筛选与 Excel/PDF 导出) |
|
| 2026-09-08 | v4.0.18 追加:HQ 财务银行账户总目录(聚合门店/酒厂/合伙人/物流有效账户;可新增不挂门店账户;筛选与 Excel/PDF 导出) |
|
||||||
|
| 2026-09-08 | HQ 合伙人详情关联码可下载裸二维码(与「下载活动图」分开);C 端门店核销次数由系统设置开关控制 |
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ export function resolveMockSmsFixedCode(env?: Record<string, string | undefined>
|
|||||||
return code || MOCK_SMS_FIXED_CODE;
|
return code || MOCK_SMS_FIXED_CODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** C 端门店是否展示核销次数(系统设置 SHOW_STORE_REDEEM_COUNT;未配置默认开) */
|
||||||
|
export function isShowStoreRedeemCountEnabled(env?: Record<string, string | undefined>): boolean {
|
||||||
|
const raw = (readEnv(env).SHOW_STORE_REDEEM_COUNT ?? '').trim().toLowerCase();
|
||||||
|
if (!raw) return true;
|
||||||
|
return raw === 'true' || raw === '1';
|
||||||
|
}
|
||||||
|
|
||||||
/** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */
|
/** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */
|
||||||
export const DEFAULT_SHARE_TITLE = '你吃饭,我买单';
|
export const DEFAULT_SHARE_TITLE = '你吃饭,我买单';
|
||||||
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
|
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export type ClientRuntimeConfig = {
|
|||||||
partnerOnboardCsHint?: string | null;
|
partnerOnboardCsHint?: string | null;
|
||||||
/** 小程序各场景分享文案/图 */
|
/** 小程序各场景分享文案/图 */
|
||||||
share?: MiniShareRuntime;
|
share?: MiniShareRuntime;
|
||||||
|
/** C 端门店列表/详情是否展示核销次数;未下发时按开启处理 */
|
||||||
|
showStoreRedeemCount?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 是否展示微信授权入口 */
|
/** 是否展示微信授权入口 */
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
|
description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'SHOW_STORE_REDEEM_COUNT',
|
||||||
|
label: 'C 端展示门店核销次数',
|
||||||
|
group: G.feature,
|
||||||
|
type: 'boolean',
|
||||||
|
requiresRestart: false,
|
||||||
|
description: '关闭后小程序门店列表/详情不再展示「核销N次」,接口也不再返回核销次数',
|
||||||
|
},
|
||||||
|
|
||||||
{ key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
|
{ key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
|
||||||
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
|
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
|
||||||
@@ -612,6 +620,7 @@ export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.k
|
|||||||
|
|
||||||
/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */
|
/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */
|
||||||
export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
|
export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
|
||||||
|
SHOW_STORE_REDEEM_COUNT: 'true',
|
||||||
USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''),
|
USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''),
|
||||||
SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''),
|
SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''),
|
||||||
BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE,
|
BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
isShowStoreRedeemCountEnabled,
|
||||||
parseMiniHomeBanners,
|
parseMiniHomeBanners,
|
||||||
resolveClientBrandRuntime,
|
resolveClientBrandRuntime,
|
||||||
resolveMiniShareRuntime,
|
resolveMiniShareRuntime,
|
||||||
@@ -44,6 +45,7 @@ export class ClientConfigController {
|
|||||||
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||
|
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||
|
||||||
'使用问题、提现问题等随时可联系【杜康好客】客服',
|
'使用问题、提现问题等随时可联系【杜康好客】客服',
|
||||||
share,
|
share,
|
||||||
|
showStoreRedeemCount: isShowStoreRedeemCountEnabled(env),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import {
|
import {
|
||||||
HqPermissionGuard,
|
HqPermissionGuard,
|
||||||
@@ -80,6 +81,17 @@ export class AdminPartnersController {
|
|||||||
return this.assoc.listUsers(BigInt(id), Number(page) || 1, Number(pageSize) || 20);
|
return this.assoc.listUsers(BigInt(id), Number(page) || 1, Number(pageSize) || 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/assoc/qrcode')
|
||||||
|
async assocQrcode(@Param('id') id: string, @Res() res: Response) {
|
||||||
|
const { buffer, fileName } = await this.assoc.getQrcodeBuffer(BigInt(id));
|
||||||
|
res.setHeader('Content-Type', 'image/png');
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${fileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||||
|
);
|
||||||
|
res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/assoc/qrcode')
|
@Post(':id/assoc/qrcode')
|
||||||
regenQrcode(@Param('id') id: string) {
|
regenQrcode(@Param('id') id: string) {
|
||||||
return this.assoc.ensureQrcode(BigInt(id), true);
|
return this.assoc.ensureQrcode(BigInt(id), true);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
|
import { loadAppConfig, ClientApp, SmsScene, isShowStoreRedeemCountEnabled } from '@dukang/shared-types';
|
||||||
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
|
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
|
||||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from '../../common/test-whitelist/test-whitelist.service';
|
} from '../../common/test-whitelist/test-whitelist.service';
|
||||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||||
import { formatPartnerWecomLabel } from './wecom-submitter-label';
|
import { formatPartnerWecomLabel } from './wecom-submitter-label';
|
||||||
|
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||||
|
|
||||||
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
@@ -86,6 +87,7 @@ export class StoreService {
|
|||||||
private readonly tencentLbs: TencentLbsProvider,
|
private readonly tencentLbs: TencentLbsProvider,
|
||||||
private readonly testWhitelist: TestWhitelistService,
|
private readonly testWhitelist: TestWhitelistService,
|
||||||
private readonly wecomPush: WecomMessagePushService,
|
private readonly wecomPush: WecomMessagePushService,
|
||||||
|
private readonly systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */
|
/** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */
|
||||||
@@ -218,11 +220,12 @@ export class StoreService {
|
|||||||
latitude?: unknown;
|
latitude?: unknown;
|
||||||
longitude?: unknown;
|
longitude?: unknown;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
redeemCount: number;
|
redeemCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv());
|
||||||
const redeemGroups =
|
const redeemGroups =
|
||||||
visible.length === 0
|
!showRedeemCount || visible.length === 0
|
||||||
? []
|
? []
|
||||||
: await this.prisma.redeemRecord.groupBy({
|
: await this.prisma.redeemRecord.groupBy({
|
||||||
by: ['storeId'],
|
by: ['storeId'],
|
||||||
@@ -252,7 +255,9 @@ export class StoreService {
|
|||||||
items.push({
|
items.push({
|
||||||
...mapped,
|
...mapped,
|
||||||
distanceMeters,
|
distanceMeters,
|
||||||
redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0,
|
...(showRedeemCount
|
||||||
|
? { redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0 }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +291,7 @@ export class StoreService {
|
|||||||
throw new NotFoundException('门店不存在');
|
throw new NotFoundException('门店不存在');
|
||||||
}
|
}
|
||||||
const coords = await this.ensureStoreCoordinates(store);
|
const coords = await this.ensureStoreCoordinates(store);
|
||||||
|
const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv());
|
||||||
const [media, packageRows, redeemCount] = await Promise.all([
|
const [media, packageRows, redeemCount] = await Promise.all([
|
||||||
this.prisma.commonResource.findMany({
|
this.prisma.commonResource.findMany({
|
||||||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
||||||
@@ -295,7 +301,7 @@ export class StoreService {
|
|||||||
where: { storeId: id },
|
where: { storeId: id },
|
||||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
}),
|
}),
|
||||||
this.prisma.redeemRecord.count({ where: { storeId: id } }),
|
showRedeemCount ? this.prisma.redeemRecord.count({ where: { storeId: id } }) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||||
return serializeBigInt(
|
return serializeBigInt(
|
||||||
@@ -304,7 +310,7 @@ export class StoreService {
|
|||||||
...rest,
|
...rest,
|
||||||
latitude: coords?.latitude ?? store.latitude,
|
latitude: coords?.latitude ?? store.latitude,
|
||||||
longitude: coords?.longitude ?? store.longitude,
|
longitude: coords?.longitude ?? store.longitude,
|
||||||
redeemCount,
|
...(showRedeemCount && redeemCount != null ? { redeemCount } : {}),
|
||||||
media,
|
media,
|
||||||
packages: packageRows.map((p) => {
|
packages: packageRows.map((p) => {
|
||||||
const imageUrls = normalizeStorePackageImageUrls({
|
const imageUrls = normalizeStorePackageImageUrls({
|
||||||
|
|||||||
Reference in New Issue
Block a user