setName('consolidate:localProjects')
->setDescription('本地项目归并:老坑爹/黑科技/魔兽→2130,卡若系→914')
->addOption('step', 's', Option::VALUE_REQUIRED, 'report|fixZero|merge|devices|all', 'report')
->addOption('dry-run', null, Option::VALUE_NONE, '只统计');
}
protected function execute(Input $input, Output $output)
{
$this->cfg = include dirname(__DIR__, 2) . '/config/legacy_source.php';
$dry = (bool)$input->getOption('dry-run');
$step = $input->getOption('step') ?: 'report';
try {
$this->src = $this->connectSource();
} catch (\Throwable $e) {
$output->writeln('未连云端,仅做本地规则归并');
$this->src = null;
}
foreach (($step === 'all' ? ['report', 'fixZero', 'merge', 'devices'] : [$step]) as $s) {
$output->writeln("=== {$s} ===");
if ($s === 'report') {
$this->stepReport($output);
} elseif ($s === 'fixZero') {
$this->stepFixZeroCompany($output, $dry);
} elseif ($s === 'merge') {
$this->stepMerge($output, $dry);
} elseif ($s === 'devices') {
$this->stepDevices($output, $dry);
}
}
return 0;
}
/**
* 设备 + 微信登录归并(需求五 §5.2/5.6)
* 规则:ck_device.memo 命中 task_name_like 关键词 → 归到 target_id;
* 冲突保护:设备已绑其他活跃项目(companyId 非 0/非 target/不在 absorb)→ 跳过并报告。
*/
private function stepDevices(Output $output, bool $dry): void
{
foreach ($this->cfg['consolidate_groups'] as $key => $g) {
$targetId = (int)$g['target_id'];
$keywords = array_map(fn($s) => trim($s, '%'), $g['task_name_like'] ?? []);
$keywords = array_values(array_filter($keywords));
if (empty($keywords)) {
$output->writeln("{$key}: 无设备关键词,跳过");
continue;
}
$absorb = array_map('intval', $g['absorb_company_ids'] ?? []);
// memo 命中关键词的设备
$q = Db::name('device')->where('deleteTime', 0)->where(function ($query) use ($keywords) {
foreach ($keywords as $kw) {
$query->whereOr('memo', 'like', '%' . $kw . '%');
}
});
$devices = $q->field('id,memo,companyId')->select();
$migrated = 0;
$skipped = [];
$wechatLoginUpdated = 0;
foreach ($devices as $d) {
$cid = (int)$d['companyId'];
if ($cid === $targetId) {
continue; // 已归属
}
// 冲突保护:绑定到其他活跃项目(非0/非target/不在absorb)→ 跳过
if ($cid > 0 && $cid !== $targetId && !in_array($cid, $absorb, true)) {
$skipped[] = ['deviceId' => (int)$d['id'], 'memo' => $d['memo'], 'companyId' => $cid];
continue;
}
if (!$dry) {
Db::name('device')->where('id', $d['id'])->update([
'companyId' => $targetId,
'updateTime' => time(),
]);
try {
$wechatLoginUpdated += (int)Db::name('device_wechat_login')
->where('deviceId', $d['id'])
->update(['companyId' => $targetId]);
} catch (\Throwable $e) {
// device_wechat_login 表/字段差异不阻断
}
}
$migrated++;
}
$output->writeln(json_encode([
'group' => $key,
'target' => $targetId,
'matched' => count($devices),
'migrated' => $migrated,
'wechatLoginUpdated' => $wechatLoginUpdated,
'skipped' => count($skipped),
'dryRun' => $dry,
], JSON_UNESCAPED_UNICODE));
foreach ($skipped as $sk) {
$output->writeln(' 跳过(冲突): device ' . $sk['deviceId']
. ' memo=' . $sk['memo'] . ' 已绑 company=' . $sk['companyId'] . '');
}
$this->backfillDeviceWechatLogin($output, $targetId, $dry);
}
}
/**
* 补 ck_device_wechat_login:按 ck_device.imei → s2_device → s2_wechat_account.wechatId
* 解决老坑爹/油条姐等同项目微信号侧栏缺失(M1)
*/
private function backfillDeviceWechatLogin(Output $output, int $targetId, bool $dry): void
{
$devices = Db::name('device')
->where('companyId', $targetId)
->where('deleteTime', 0)
->field('id,imei,companyId,memo')
->select();
$inserted = 0;
$updated = 0;
$skipped = 0;
$now = time();
foreach ($devices as $d) {
$imei = trim((string)($d['imei'] ?? ''));
if ($imei === '') {
continue;
}
try {
$s2DeviceId = Db::table('s2_device')->where('imei', $imei)->value('id');
if (!$s2DeviceId) {
continue;
}
$accounts = Db::table('s2_wechat_account')
->where('currentDeviceId', $s2DeviceId)
->field('wechatId,wechatAlive,alias,nickname')
->select();
} catch (\Throwable $e) {
continue;
}
foreach ($accounts as $acc) {
$wechatId = trim((string)($acc['wechatId'] ?? ''));
if ($wechatId === '') {
continue;
}
$exists = Db::name('device_wechat_login')
->where('deviceId', $d['id'])
->where('wechatId', $wechatId)
->find();
if ($exists) {
$skipped++;
if (!$dry && (int)($exists['companyId'] ?? 0) !== $targetId) {
Db::name('device_wechat_login')->where('id', $exists['id'])->update([
'companyId' => $targetId,
'updateTime' => $now,
]);
$updated++;
}
continue;
}
if (!$dry) {
Db::name('device_wechat_login')->insert([
'deviceId' => (int)$d['id'],
'wechatId' => $wechatId,
'alive' => (int)($acc['wechatAlive'] ?? 0),
'companyId' => $targetId,
'createTime' => $now,
'updateTime' => $now,
'isTips' => 0,
]);
}
$inserted++;
}
}
$output->writeln(json_encode([
'backfillWechatLogin' => true,
'target' => $targetId,
'devicesScanned' => count($devices),
'inserted' => $inserted,
'companyIdUpdated' => $updated,
'skippedExisting' => $skipped,
'dryRun' => $dry,
], JSON_UNESCAPED_UNICODE));
}
private function connectSource(): \PDO
{
$c = $this->cfg;
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $c['host'], $c['port'], $c['database']);
return new \PDO($dsn, $c['username'], $c['password'], [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
}
private function stepReport(Output $output): void
{
foreach ($this->cfg['consolidate_groups'] as $key => $g) {
$tid = (int)$g['target_id'];
$plans = (int)Db::name('customer_acquisition_task')->where('companyId', $tid)->where('deleteTime', 0)->count();
$cust = (int)Db::query(
'SELECT COUNT(*) c FROM ck_task_customer c INNER JOIN ck_customer_acquisition_task t ON c.task_id=t.id WHERE t.companyId=?',
[$tid]
)[0]['c'];
$libs = (int)Db::name('content_library')->where(['companyId' => $tid, 'isDel' => 0])->count();
$output->writeln("{$key} → company {$tid}: 计划 {$plans} | 客资 {$cust} | 内容库 {$libs}");
}
$zero = (int)Db::name('customer_acquisition_task')->whereRaw('companyId=0')->count();
$output->writeln("companyId=0 计划: {$zero}");
}
private function stepFixZeroCompany(Output $output, bool $dry): void
{
if (!$this->src) {
$output->writeln('需要连云端修复 companyId=0 的 legacy 计划');
return;
}
$map = $this->cfg['company_map'] ?? [];
$tasks = Db::name('customer_acquisition_task')->whereRaw('companyId=0')->where('isLegacy', 1)->select();
$fixed = 0;
foreach ($tasks as $t) {
$legacyId = (int)$t['legacyId'];
$stmt = $this->src->prepare('SELECT AccountId FROM FriendRequestTask WHERE lId=?');
$stmt->execute([$legacyId]);
$accId = (int)$stmt->fetchColumn();
$companyId = (int)($map[$accId] ?? 0);
$companyId = $companyId > 0 ? $companyId : $this->resolveByGroupRules($accId, (string)$t['name']);
if ($companyId <= 0) {
$companyId = 914;
}
if (!$dry) {
Db::name('customer_acquisition_task')->where('id', $t['id'])->update([
'companyId' => $companyId,
'updateTime' => time(),
]);
}
$fixed++;
}
$output->writeln($dry ? "将修复 {$fixed} 条 companyId=0" : "已修复 {$fixed} 条 companyId=0");
}
private function resolveByGroupRules(int $accountId, string $taskName): int
{
foreach ($this->cfg['consolidate_groups'] as $g) {
if (in_array($accountId, $g['legacy_account_ids'] ?? [], true)) {
return (int)$g['target_id'];
}
foreach ($g['task_name_like'] ?? [] as $like) {
$needle = trim($like, '%');
if ($needle !== '' && mb_strpos($taskName, $needle) !== false) {
return (int)$g['target_id'];
}
}
}
return 0;
}
private function stepMerge(Output $output, bool $dry): void
{
$now = time();
foreach ($this->cfg['consolidate_groups'] as $key => $g) {
$targetId = (int)$g['target_id'];
$fromIds = array_map('intval', $g['absorb_company_ids'] ?? []);
$taskIds = $this->collectTaskIdsToMove($g, $fromIds, $targetId);
if (!$dry) {
Db::name('company')->where('id', $targetId)->update([
'memo' => $g['memo'] ?? '',
'updateTime' => $now,
]);
}
$stats = ['plans' => 0, 'libs' => 0, 'pool' => 0, 'tags_cat' => 0, 'users' => 0];
if ($taskIds) {
if (!$dry) {
$stats['plans'] = Db::name('customer_acquisition_task')
->whereIn('id', $taskIds)
->update(['companyId' => $targetId, 'updateTime' => $now]);
} else {
$stats['plans'] = count($taskIds);
}
}
$allFrom = array_unique(array_merge($fromIds, $this->findCompaniesByTaskKeywords($g)));
foreach ($allFrom as $fromId) {
if ($fromId === $targetId || $fromId <= 0) {
continue;
}
$stats = $this->moveCompanyScopedRows($fromId, $targetId, $dry, $stats);
}
// 计划名命中但 company 不在 absorb 列表(如 914 上的魔兽计划 → 2130)
$keywordTaskIds = $this->findTaskIdsByKeywordsOnly($g, $targetId);
if ($keywordTaskIds && !$dry) {
Db::name('customer_acquisition_task')->whereIn('id', $keywordTaskIds)
->update(['companyId' => $targetId, 'updateTime' => $now]);
$stats['plans'] += count($keywordTaskIds);
} elseif ($keywordTaskIds) {
$stats['plans'] += count($keywordTaskIds);
}
$onlyAbsorb = array_filter($fromIds, fn($id) => $id > 0 && $id !== $targetId);
foreach ($onlyAbsorb as $fromId) {
if (!$dry) {
Db::name('company')->where('id', $fromId)->update([
'deleteTime' => $now,
'memo' => '已合并至项目' . $targetId . '(' . $key . ')',
'updateTime' => $now,
]);
}
}
$output->writeln(json_encode(['group' => $key, 'target' => $targetId, 'stats' => $stats], JSON_UNESCAPED_UNICODE));
}
}
private function collectTaskIdsToMove(array $g, array $fromIds, int $targetId): array
{
$ids = [];
if ($fromIds) {
$rows = Db::name('customer_acquisition_task')->whereIn('companyId', $fromIds)->column('id');
$ids = array_merge($ids, $rows);
}
return array_values(array_unique($ids));
}
private function findCompaniesByTaskKeywords(array $g): array
{
$q = Db::name('customer_acquisition_task')->where('deleteTime', 0);
$q->where(function ($query) use ($g) {
$first = true;
foreach ($g['task_name_like'] ?? [] as $like) {
if ($first) {
$query->whereLike('name', $like);
$first = false;
} else {
$query->whereOr('name', 'like', $like);
}
}
});
return array_values(array_unique($q->column('companyId')));
}
private function findTaskIdsByKeywordsOnly(array $g, int $targetId): array
{
if (empty($g['task_name_like'])) {
return [];
}
$q = Db::name('customer_acquisition_task')->where('deleteTime', 0)->where('companyId', '<>', $targetId);
$q->where(function ($query) use ($g) {
$first = true;
foreach ($g['task_name_like'] as $like) {
if ($first) {
$query->whereLike('name', $like);
$first = false;
} else {
$query->whereOr('name', 'like', $like);
}
}
});
return $q->column('id');
}
private function moveCompanyScopedRows(int $fromId, int $targetId, bool $dry, array $stats): array
{
$tables = [
'content_library' => 'libs',
'traffic_pool_company' => 'pool',
'traffic_pool_tag_category' => 'tags_cat',
'traffic_pool_tag_define' => 'tags_cat',
'traffic_pool_tag' => 'tags_cat',
'traffic_source' => 'pool',
'wechat_tag' => 'pool',
'plan_tags' => 'pool',
'acquisition_global_settings' => 'pool',
'users' => 'users',
'tokens_company' => 'users',
];
foreach ($tables as $table => $key) {
try {
$cnt = (int)Db::name($table)->where('companyId', $fromId)->count();
if ($cnt > 0 && !$dry) {
Db::name($table)->where('companyId', $fromId)->update(['companyId' => $targetId]);
}
$stats[$key] = ($stats[$key] ?? 0) + $cnt;
} catch (\Throwable $e) {
// 表不存在或字段不同则跳过
}
}
return $stats;
}
}