This commit is contained in:
Your Name
2026-08-31 15:17:34 +08:00
parent ed48f8be31
commit 456dd667df
439 changed files with 5720 additions and 422 deletions
@@ -8,11 +8,48 @@ use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\firstvisit\WecomAcquisitionCustomerLogic;
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
use app\common\service\qywx\QywxPromotionContactApiService;
use app\common\service\qywx\QywxPromotionMediaService;
class WecomPromotionController extends BaseAdminController
{
private const PAGE_PERMISSION = 'firstvisit.wecomPromotion/overview';
public function tagOptions()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->tagOptions()));
}
public function createTag()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
if (!$this->request->isPost()) {
return $this->fail('请使用 POST 创建标签');
}
$name = $this->request->post('name', '');
if (!is_string($name)) {
return $this->fail('标签名称格式不正确');
}
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->createTag($name)));
}
public function uploadWelcomeMedia()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->data((new QywxPromotionMediaService())->upload(
$this->request->file('file'),
(string) $this->request->post('type', ''),
$this->adminId
)));
}
public function overview()
{
if (!$this->hasPagePermission()) {
@@ -425,6 +425,18 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('关联支付单成功', $result);
}
/** 解除单笔收款关联,总金额不变,同步更新已付金额和需代收。 */
public function unlinkPayOrder()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('unlinkPayOrder');
$result = PrescriptionOrderLogic::unlinkPayOrder($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('收款关联已移除,金额已同步更新', $result);
}
/**
* 已发货/已签收:仅提交完单申请(不新增/关联支付单),并重置支付审核为待审核
*/
@@ -638,7 +638,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$allOids = array_values(array_unique($allOids));
$amountByOid = [];
if ($allOids !== []) {
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')->column('amount', 'id');
// 与详情已付总额一致:退款记录可展示,但不再计入实付。
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')
->whereIn('status', [2, 5])->column('amount', 'id');
}
foreach ($poIds as $pid) {
$s = 0.0;
@@ -1161,6 +1163,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
->join('order o', 'l.pay_order_id = o.id')
->whereIn('l.prescription_order_id', $poIds)
->whereNull('o.delete_time')
->whereIn('o.status', [2, 5])
->sum('o.amount');
return round($sum, 2);
@@ -144,7 +144,7 @@ class FirstVisitConversionLogic
}
unset($row);
}
return [
return self::withDeletedFansVisibility([
'meta' => [
'time_type' => $timeType,
'time_label' => $timeLabel,
@@ -178,7 +178,7 @@ class FirstVisitConversionLogic
],
'rows' => $rows,
'target' => $target,
];
], $adminInfo);
}
/** @return array<string,mixed> */
@@ -190,14 +190,14 @@ class FirstVisitConversionLogic
);
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
$empty = [
$empty = self::withDeletedFansVisibility([
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'date_range' => [$context['start_date'], $context['end_date']],
'entity' => null,
];
], $adminInfo, true);
$entityType = strtolower(trim((string) ($params['entity_type'] ?? '')));
if (!in_array($entityType, ['dept', 'member'], true)) {
@@ -253,9 +253,54 @@ class FirstVisitConversionLogic
];
unset($result['deleted_count']);
return self::withDeletedFansVisibility($result, $adminInfo, true);
}
/**
* 删除客户统计是账号专属能力,与root、角色、财务权限和DataScope无关。
* adminInfo由认证token缓存提供;缺失账号时拒绝,不能从HTTP参数补齐或标准化账号。
*/
private static function canViewDeletedFans(array $adminInfo): bool
{
return ($adminInfo['account'] ?? null) === 'admin';
}
/**
* 只裁剪本页响应,不改变通用统计口径、加粉客户集合、排序或分页。
* 对整个响应递归处理,避免嵌套成员、排名或未来新增位置泄露同一敏感指标。
*/
private static function withDeletedFansVisibility(array $result, array $adminInfo, bool $detail = false): array
{
$canView = self::canViewDeletedFans($adminInfo);
if (!$canView) {
$fields = $detail
? ['deleted_fans_count', 'deleted_count', 'is_deleted', 'delete_time']
: ['deleted_fans_count', 'deleted_count'];
$result = self::removeDeletedFansFields($result, $fields);
}
if ($detail) {
$result['can_view_deleted_fans'] = $canView;
} else {
$result['meta']['can_view_deleted_fans'] = $canView;
}
return $result;
}
/** @param string[] $fields */
private static function removeDeletedFansFields(array $value, array $fields): array
{
foreach ($fields as $field) {
unset($value[$field]);
}
foreach ($value as &$item) {
if (is_array($item)) {
$item = self::removeDeletedFansFields($item, $fields);
}
}
unset($item);
return $value;
}
/**
* Resolve only the clicked entity and its authorized target range. This is
* deliberately structural: it avoids recomputing all overview metrics,
@@ -9,6 +9,9 @@ use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
use app\common\service\qywx\QywxPromotionMemberRange;
use app\common\service\qywx\QywxPromotionConfig;
use app\common\service\qywx\QywxPromotionContactApiService;
use app\common\service\qywx\QywxPromotionMediaService;
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionRangeSyncService;
use app\common\service\qywx\QywxPromotionWidgetService;
@@ -49,6 +52,7 @@ class WecomPromotionLogic
$domain = self::publicDomain($domain);
foreach ($pools as &$pool) {
$pool['automation_config'] = QywxPromotionConfig::forPool((int) $pool['id']);
$pool['widget_config'] = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
unset($pool['widget_config_json']);
$key = (string) $pool['public_key'];
@@ -133,7 +137,11 @@ class WecomPromotionLogic
$memberRules = $memberRulesByPool[(int) $pool['id']] ?? [];
$sync = $syncByPool[(int) $pool['id']] ?? [];
$remoteUserIds = array_fill_keys((array) ($officialLink['range_userids'] ?? []), true);
$reception = QywxPromotionMemberRange::evaluate($memberRules, $today, time(), $pool['automation_config']);
$availableUserIds = array_fill_keys($reception['userids'], true);
foreach ($memberRules as &$memberRule) {
$memberRule['is_backup'] = in_array((int) ($memberRule['admin_id'] ?? 0), $pool['automation_config']['backup_member_admin_ids'], true);
$memberRule['reception_available'] = isset($availableUserIds[(string) ($memberRule['userid'] ?? '')]);
$memberRule['is_current'] = false;
$memberRule['is_applied'] = false;
$memberRule['is_in_remote_range'] = isset($remoteUserIds[(string) ($memberRule['userid'] ?? '')]);
@@ -157,6 +165,7 @@ class WecomPromotionLogic
$pool['official_link_count'] = count($officialLinks);
$pool['legacy_link_count'] = $legacyCount;
$pool['member_admin_ids'] = array_values(array_unique($memberAdminIds));
$pool['member_admin_ids'] = array_values(array_diff($pool['member_admin_ids'], $pool['automation_config']['backup_member_admin_ids']));
$pool['member_rules'] = $memberRules;
$pool['operators'] = $operatorsByPool[(int) $pool['id']] ?? [];
$pool['operator_admin_ids'] = array_values(array_map(
@@ -168,6 +177,7 @@ class WecomPromotionLogic
$pool['can_manage_access'] = self::ownerInScope((int) ($pool['owner_admin_id'] ?? 0), $visibleIds);
$pool['can_delete'] = $pool['can_manage_access'];
$pool['dispatch_sync'] = $sync;
$pool['using_backup'] = $reception['using_backup'];
$pool['skip_verify'] = (int) ($officialLink['skip_verify'] ?? 0);
$pool['migration_state'] = count($officialLinks) > 1
? 'needs_resolution'
@@ -201,6 +211,7 @@ class WecomPromotionLogic
'member_options' => $memberOptions,
'operator_options' => $operatorOptions,
'department_options' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
'automation_installed' => QywxPromotionConfig::installed(),
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
];
}
@@ -220,9 +231,49 @@ class WecomPromotionLogic
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
}
$members = self::resolveMembers((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo, $id);
$automation = null;
if (array_key_exists('automation_config', $params)) {
QywxPromotionConfig::assertInstalled();
if (!is_array($params['automation_config'])) {
throw new RuntimeException('获客配置格式不正确');
}
$automation = QywxPromotionConfig::normalize($params['automation_config']);
$automation = (new QywxPromotionMediaService())->validateConfig(
$automation, $adminId, $id > 0 ? QywxPromotionConfig::forPool($id) : []
);
if ($automation['tags_enabled']) {
$knownTags = [];
foreach ((new QywxPromotionContactApiService())->tagOptions()['tag_groups'] as $group) {
foreach ($group['tag'] as $tag) {
$knownTags[] = (string) $tag['id'];
}
}
if (array_diff($automation['tag_ids'], $knownTags) !== []) {
throw new RuntimeException('所选企业微信标签已删除或不在应用可用范围,请刷新标签后重新选择');
}
}
} elseif ($id > 0 && QywxPromotionConfig::installed()) {
$automation = QywxPromotionConfig::forPool($id);
}
$primaryIds = self::normalizePositiveIds((array) ($params['member_admin_ids'] ?? []));
$backupIds = $automation['backup_member_admin_ids'] ?? [];
if ($primaryIds === [] || array_intersect($primaryIds, $backupIds) !== []) {
throw new RuntimeException('请选择接待成员,且备用员工不能与接待成员重复');
}
$members = self::resolveMembers(array_merge($primaryIds, $backupIds), $adminId, $adminInfo, $id);
if ($automation !== null) {
$userIdByAdmin = array_column($members, 'userid', 'id');
$automation['backup_userids'] = array_values(array_map(static fn ($aid) => $userIdByAdmin[$aid], $backupIds));
foreach ($automation['reception_schedule'] as &$slot) {
if (array_diff($slot['member_admin_ids'], $primaryIds) !== []) {
throw new RuntimeException('接待时段只能选择方案内的接待成员');
}
$slot['member_userids'] = array_values(array_map(static fn ($aid) => $userIdByAdmin[$aid], $slot['member_admin_ids']));
}
unset($slot);
}
$userIds = array_values(array_column($members, 'userid'));
$eligibleUserIds = self::eligibleSelectedUserIds($id, $members);
$eligibleUserIds = self::eligibleSelectedUserIds($id, $members, $automation ?? []);
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
$status = (int) ($params['status'] ?? 1) === 1 ? 1 : 0;
$now = time();
@@ -288,6 +339,7 @@ class WecomPromotionLogic
$status,
$existingPool,
$members,
$automation,
$skipVerify,
$createdRemote,
$now,
@@ -333,6 +385,9 @@ class WecomPromotionLogic
]);
}
self::persistPoolMembers($id, $members, $now);
if ($automation !== null) {
QywxPromotionConfig::save($id, $automation);
}
if ($createdRemote) {
QywxPromotionMemberSchedulerService::initialisePool($id, $linkId);
}
@@ -1061,7 +1116,7 @@ class WecomPromotionLogic
}
/** @param list<array{id:int,userid:string}> $members @return list<string> */
private static function eligibleSelectedUserIds(int $poolId, array $members): array
private static function eligibleSelectedUserIds(int $poolId, array $members, array $config = []): array
{
if ($members === []) {
throw new RuntimeException('请至少选择一名获客成员');
@@ -1089,7 +1144,7 @@ class WecomPromotionLogic
$rule['userid'] = $userId;
$candidates[] = $rule;
}
$range = QywxPromotionMemberRange::evaluate($candidates, $today, $now);
$range = QywxPromotionMemberRange::evaluate($candidates, $today, $now, $config);
if ($range['userids'] === []) {
throw new RuntimeException('至少需要一名已启用、已生效且未达到今日上限的获客医助');
}
@@ -1110,6 +1165,7 @@ class WecomPromotionLogic
Db::name('qywx_promotion_pool_member')->where('id', (int) $existing['id'])->update([
'admin_id' => (int) $member['id'],
'userid' => $userId,
'enabled' => $existing['delete_time'] !== null ? 1 : (int) $existing['enabled'],
'delete_time' => null,
'update_time' => $now,
]);
@@ -799,6 +799,31 @@ class PrescriptionOrderLogic
return null;
}
/** 收款关联增删共用订单行锁,失败时连同金额和日志一起回滚。 */
private static function mutatePayOrderLinks(int $id, callable $mutation)
{
self::$error = '';
try {
return Db::transaction(static function () use ($id, $mutation) {
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->lock(true)->find();
if (!$order) {
throw new \DomainException('订单不存在');
}
$result = $mutation();
if ($result === false) {
throw new \RuntimeException(self::$error ?: '收款关联变更失败');
}
return $result;
});
} catch (\Throwable $e) {
if (self::$error === '') {
self::$error = $e->getMessage();
}
return false;
}
}
/**
* @param int[] $payOrderIds
*/
@@ -2729,6 +2754,14 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false
*/
public static function addPayOrder(array $params, int $adminId, array $adminInfo)
{
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::addPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function addPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$id = (int) $params['id'];
@@ -2833,6 +2866,14 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false
*/
public static function linkPayOrder(array $params, int $adminId, array $adminInfo)
{
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::linkPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function linkPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$id = (int) $params['id'];
@@ -2938,6 +2979,88 @@ class PrescriptionOrderLogic
return $out;
}
/**
* 移除单笔收款关联:保留原支付单、总金额与履约/审核状态,同步已付及代收金额。
* 普通订单 paid 按剩余有效收款重算;退款订单的 paid 保留退款后的余额口径。
*/
public static function unlinkPayOrder(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
// 显式校验独立权限,菜单迁移未执行时也不能绕过鉴权中间件的默认放行逻辑。
if ((int) ($adminInfo['root'] ?? 0) !== 1
&& !in_array('tcm.prescriptionOrder/unlinkPayOrder', AuthLogic::getAuthByAdminId($adminId), true)) {
self::$error = '无权限移除收款关联';
return false;
}
return self::mutatePayOrderLinks(
(int) ($params['id'] ?? 0),
static fn () => self::unlinkPayOrderLocked($params, $adminId, $adminInfo)
);
}
private static function unlinkPayOrderLocked(array $params, int $adminId, array $adminInfo)
{
$id = (int) $params['id'];
$payOrderId = (int) ($params['pay_order_id'] ?? 0);
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::$error = '无权限操作此订单';
return false;
}
if (in_array((int) $order->fulfillment_status, [3, 4], true)) {
self::$error = '已完成或已取消的订单不允许移除收款关联';
return false;
}
if (!in_array($payOrderId, self::linkedPayOrderIdList($id), true)) {
self::$error = '该收款记录未关联当前订单,请刷新后重试';
return false;
}
$payOrder = Order::where('id', $payOrderId)->whereNull('delete_time')->lock(true)->find();
if (!$payOrder || !in_array((int) $payOrder->status, [2, 5], true)) {
self::$error = '仅已支付或待审核的收款记录可移除,已退款记录不可移除';
return false;
}
$oldPaidCents = (int) round((float) $order->paid * 100);
$removedCents = (int) round((float) $payOrder->amount * 100);
if ($removedCents < 0) {
self::$error = '收款金额异常,请先核对金额';
return false;
}
$deleted = PrescriptionOrderPayOrder::where('prescription_order_id', $id)
->where('pay_order_id', $payOrderId)->delete();
if ($deleted !== 1) {
throw new \RuntimeException('收款关联已变化,请刷新后重试');
}
$remainingIds = self::linkedPayOrderIdList($id);
$remainingPaidCents = $remainingIds === [] ? 0 : (int) round((float) Order::whereIn('id', $remainingIds)
->whereNull('delete_time')->whereIn('status', [2, 5])->sum('amount') * 100);
$order->linked_pay_order_id = $remainingIds[0] ?? null;
// 部分退款可能未拆分收款单金额,不能用剩余收款原额覆盖退款后的 paid 余额。
$hasRefund = (float) ($order->refund_amount ?? 0) > 0 || (int) $order->fulfillment_status === 10;
$newPaidCents = $hasRefund
? min($remainingPaidCents, max(0, $oldPaidCents - $removedCents))
: $remainingPaidCents;
$order->paid = $newPaidCents / 100;
// 与详情的关联已付总额口径一致;订单 amount 不变。
$order->agency_collect_amount = round((float) $order->amount - $remainingPaidCents / 100, 2);
$order->save();
self::writeLog($id, $adminId, $adminInfo, 'unlink_pay_order', sprintf(
'移除收款关联 #%d(%s,¥%.2f),订单总金额 ¥%.2f 不变;已付金额(paid)¥%.2f → ¥%.2f;原收款记录保留',
$payOrderId, (string) $payOrder->order_no, $removedCents / 100,
(float) $order->amount, $oldPaidCents / 100, $newPaidCents / 100
), true);
$out = PrescriptionOrder::where('id', $id)->find()->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
/**
* 已发货/已签收订单:不新增/关联支付单,仅提交完单申请并将支付审核置为待审核。
*
@@ -5484,7 +5607,7 @@ class PrescriptionOrderLogic
$log->save();
} catch (\Throwable $e) {
if ($strict) {
throw new \RuntimeException('操作日志写入失败,快递信息未保存', 0, $e);
throw new \RuntimeException('操作日志写入失败,变更未保存', 0, $e);
}
// 非关键日志沿用历史容错行为
}
@@ -88,6 +88,7 @@ class PrescriptionOrderValidate extends BaseValidate
'paidPayOrders' => ['diagnosis_id'],
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
'linkPayOrder' => ['id', 'pay_order_id'],
'unlinkPayOrder' => ['id', 'pay_order_id'],
'requestCompletion' => ['id'],
'complete' => ['id', 'fulfillment_status'],
'refund' => ['id', 'reason', 'refund_amount'],
@@ -101,6 +102,13 @@ class PrescriptionOrderValidate extends BaseValidate
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
];
public function sceneUnlinkPayOrder(): PrescriptionOrderValidate
{
return $this->only(['id', 'pay_order_id'])
->append('id', 'require|integer|gt:0')
->append('pay_order_id', 'require|integer|gt:0');
}
public function updateAmount(): PrescriptionOrderValidate
{
return $this->only(['id', 'amount'])