Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f48f79d2f3 | |||
| 0da72778c3 | |||
| d568bf84eb | |||
| 752d80af7c | |||
| 5265b82ad1 | |||
| 3318498587 | |||
| cbfd9738de |
@@ -2,6 +2,14 @@
|
||||
# .gitignore — Лидерра
|
||||
# =============================================================================
|
||||
|
||||
# ── Session junk (broken PS paths from parallel Claude sessions, deploy tarballs, ad-hoc screenshots) ──
|
||||
CTemp*
|
||||
CWindowsTemp*
|
||||
phase[0-9]*-update.tar.gz
|
||||
recheck-*.png
|
||||
.tmp-*.sql
|
||||
tools/cloudflared.*
|
||||
|
||||
# ── Node / npm ──────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
@@ -278,19 +278,19 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'deal_id' => $existingMergeable->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Обновляем source_crm_id и опционально received_at через
|
||||
// DB::table (надёжнее Eloquent save() на партиционированной таблице).
|
||||
$newReceivedAt = ($lead->received_at !== null && $lead->received_at->gt($existingMergeable->received_at))
|
||||
? $lead->received_at
|
||||
: null;
|
||||
$updateData = ['source_crm_id' => $lead->vid, 'updated_at' => now()];
|
||||
if ($newReceivedAt !== null) {
|
||||
$updateData['received_at'] = $newReceivedAt;
|
||||
}
|
||||
// Обновляем только source_crm_id + updated_at через DB::table.
|
||||
// NB (регрессия 26.05.2026 04:12-05:03 UTC, 9 failed_jobs):
|
||||
// received_at — partition key, и lead_charges имеет FK
|
||||
// (deal_id, deal_received_at) с ON DELETE CASCADE, но
|
||||
// ON UPDATE NO ACTION (default). Любое изменение received_at
|
||||
// ломает FK даже в той же месячной партиции (даже DEFERRABLE
|
||||
// INITIALLY DEFERRED не помогает — проверка падает на COMMIT).
|
||||
// CSV-recovered received_at сохраняем как есть — отличие на минуты
|
||||
// несущественно, чем риск каскадного DELETE lead_charges.
|
||||
DB::table('deals')
|
||||
->where('id', $existingMergeable->id)
|
||||
->where('received_at', $existingMergeable->received_at)
|
||||
->update($updateData);
|
||||
->update(['source_crm_id' => $lead->vid, 'updated_at' => now()]);
|
||||
|
||||
Log::info('supplier_lead.merged_into_csv_recovered', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
|
||||
@@ -532,3 +532,94 @@ it('caps deal creation at 3 recipients and tags deal with subject from payload',
|
||||
expect($deals)->toHaveCount(3)
|
||||
->and($deals->pluck('subject_code')->unique()->all())->toBe([82]);
|
||||
});
|
||||
|
||||
it('merges webhook into csv-recovered deal even when received_at differs (Phase 2 FK fix)', function (): void {
|
||||
// Регрессия 26.05.2026 04:12-05:03 UTC: 9 RouteSupplierLeadJob упали с
|
||||
// SQLSTATE 23503 (FK violation) при попытке Phase 2 merge обновить deals.received_at.
|
||||
// Причина — lead_charges имеет FK на (deal_id, deal_received_at) с
|
||||
// ON DELETE CASCADE, но ON UPDATE NO ACTION (default). Даже DEFERRABLE INITIALLY
|
||||
// DEFERRED не помогает — проверка падает на COMMIT. Фикс: оставить received_at
|
||||
// CSV-recovered deal'а нетронутым (отличие на минуты несущественно).
|
||||
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'phase2-merge.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'phase2-merge.ru',
|
||||
'is_active' => true,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
// CSV-recovered deal: source_crm_id=NULL, received_at в прошлом.
|
||||
$csvReceivedAt = now()->subMinutes(15);
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$csvDeal = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'source_crm_id' => null,
|
||||
'project_id' => $project->id,
|
||||
'phone' => '79991234567',
|
||||
'phones' => ['79991234567'],
|
||||
'status' => 'new',
|
||||
'received_at' => $csvReceivedAt,
|
||||
]);
|
||||
|
||||
// LeadCharge на CSV-recovered deal — это что триггерит FK при UPDATE received_at.
|
||||
\App\Models\LeadCharge::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'deal_id' => $csvDeal->id,
|
||||
'deal_received_at' => $csvDeal->received_at,
|
||||
'charge_source' => 'rub',
|
||||
]);
|
||||
|
||||
// Webhook lead: реальный vid, тот же phone+project, received_at позже CSV.
|
||||
$webhookVid = 999111;
|
||||
$webhookReceivedAt = now(); // > csvReceivedAt → старый код триггерил UPDATE received_at.
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'vid' => $webhookVid,
|
||||
'phone' => '79991234567',
|
||||
'received_at' => $webhookReceivedAt,
|
||||
'raw_payload' => [
|
||||
'vid' => $webhookVid,
|
||||
'project' => 'B1_phase2-merge.ru',
|
||||
'phone' => '79991234567',
|
||||
'phones' => ['79991234567'],
|
||||
'time' => $webhookReceivedAt->getTimestamp(),
|
||||
],
|
||||
]);
|
||||
|
||||
// Не должно бросать FK violation — merge обновляет ТОЛЬКО source_crm_id.
|
||||
runRouteJob($lead->id);
|
||||
|
||||
$lead->refresh();
|
||||
expect($lead->processed_at)->not->toBeNull();
|
||||
|
||||
// Deal обновлён: source_crm_id заполнен webhook vid, received_at не тронут.
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$merged = Deal::query()
|
||||
->whereKey($csvDeal->id)
|
||||
->where('received_at', $csvReceivedAt)
|
||||
->first();
|
||||
expect($merged)->not->toBeNull();
|
||||
expect($merged->source_crm_id)->toBe($webhookVid);
|
||||
|
||||
// Без второго списания — balance не изменился (chargeForDelivery в merge-ветке не вызывается).
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('100000.00');
|
||||
|
||||
// supplier_lead_deliveries — линк создан.
|
||||
$deliveryCount = DB::table('supplier_lead_deliveries')
|
||||
->where('supplier_lead_id', $lead->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->count();
|
||||
expect($deliveryCount)->toBe(1);
|
||||
|
||||
// Никаких дублей deals — только один с этим vid.
|
||||
expect(Deal::query()->where('source_crm_id', $webhookVid)->count())->toBe(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
BEGIN;
|
||||
CREATE TEMP TABLE dups AS
|
||||
SELECT d.id AS deal_id, lc.id AS charge_id, lc.price_per_lead_kopecks
|
||||
FROM deals d
|
||||
JOIN lead_charges lc ON lc.deal_id = d.id
|
||||
WHERE d.tenant_id=2
|
||||
AND d.created_at::date = DATE '2026-05-25'
|
||||
AND d.source_crm_id IS NULL
|
||||
AND d.deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM deals d2
|
||||
WHERE d2.tenant_id=d.tenant_id
|
||||
AND d2.phone=d.phone
|
||||
AND d2.project_id=d.project_id
|
||||
AND d2.source_crm_id IS NOT NULL
|
||||
AND d2.created_at::date = DATE '2026-05-25'
|
||||
AND d2.deleted_at IS NULL
|
||||
);
|
||||
|
||||
\echo === dups to clean ===
|
||||
SELECT COUNT(*) AS dup_count, (SUM(price_per_lead_kopecks)/100.0)::numeric(12,2) AS refund_rub FROM dups;
|
||||
|
||||
\echo === refund balance ===
|
||||
UPDATE tenants
|
||||
SET balance_rub = balance_rub + (SELECT (SUM(price_per_lead_kopecks)/100.0)::numeric(14,2) FROM dups),
|
||||
delivered_in_month = GREATEST(0, delivered_in_month - (SELECT COUNT(*)::int FROM dups))
|
||||
WHERE id = 2
|
||||
RETURNING id, balance_rub, delivered_in_month;
|
||||
|
||||
\echo === insert refund txns ===
|
||||
WITH ins AS (
|
||||
INSERT INTO balance_transactions(tenant_id, type, amount_leads, amount_rub, balance_leads_after, balance_rub_after, related_type, related_id, created_at)
|
||||
SELECT 2, 'refund', NULL, (price_per_lead_kopecks/100.0)::numeric(14,2), NULL,
|
||||
(SELECT balance_rub FROM tenants WHERE id=2),
|
||||
'App\Models\Deal', deal_id, NOW()
|
||||
FROM dups
|
||||
RETURNING id
|
||||
)
|
||||
SELECT COUNT(*) AS refund_txns_inserted FROM ins;
|
||||
|
||||
\echo === soft delete deals ===
|
||||
WITH upd AS (
|
||||
UPDATE deals SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id IN (SELECT deal_id FROM dups)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT COUNT(*) AS deals_soft_deleted FROM upd;
|
||||
|
||||
COMMIT;
|
||||
|
||||
\echo === verify ===
|
||||
SELECT id, balance_rub, delivered_in_month FROM tenants WHERE id=2;
|
||||
SELECT COUNT(*) AS refund_txns FROM balance_transactions WHERE tenant_id=2 AND type='refund' AND created_at > NOW() - interval '5 minutes';
|
||||
SELECT COUNT(*) AS remaining_active_dup_pairs FROM (SELECT phone, project_id FROM deals WHERE tenant_id=2 AND created_at::date = DATE '2026-05-25' AND deleted_at IS NULL GROUP BY phone, project_id HAVING COUNT(*) > 1) t;
|
||||
@@ -0,0 +1,33 @@
|
||||
# deploy/
|
||||
|
||||
Скрипты применения обновлений на боевом сервере liderra.ru.
|
||||
|
||||
## redeploy.sh
|
||||
|
||||
Server-side половина деплоя. На боевом лежит в `/var/www/liderra/redeploy.sh`
|
||||
(вне репозитория Laravel). Здесь — каноническая копия для версионирования
|
||||
и аудита.
|
||||
|
||||
**Workflow деплоя:**
|
||||
|
||||
1. **Локально** — собрать архив кода + Vite-сборку:
|
||||
```bash
|
||||
git archive HEAD app/ db/ | gzip > /tmp/deploy-code.tgz
|
||||
tar czf /tmp/deploy-build.tgz -C app/public build/
|
||||
```
|
||||
2. **scp** обоих архивов на сервер.
|
||||
3. **На сервере** — распаковать в `/var/www/liderra/app/`, выставить владельца
|
||||
`www-data:www-data`, запустить `bash /var/www/liderra/redeploy.sh`.
|
||||
|
||||
**NB:** `redeploy.sh` НЕ делает `git pull` — он рассчитан на то, что код
|
||||
уже залит scp. Если запустить без предварительного scp — будет no-op
|
||||
(composer install / migrate / optimize / restart на той же кодовой базе).
|
||||
|
||||
**Квирк 107 (фикс встроен):** строка `sudo -u www-data php artisan optimize`
|
||||
обязательна. Без неё `optimize` запускался от `ubuntu` → `bootstrap/cache/config.php`
|
||||
с владельцем `ubuntu` → php-fpm (под `www-data`) не мог прочитать → 503 на всём
|
||||
портале. Инцидент 24.05.2026 03:46 UTC, портал лежал 18 минут.
|
||||
|
||||
**Расхождение с боевым:** если правится этот файл — синкать на боевой
|
||||
(scp + проверка хеша). Боевой = source of truth для исполнения, репо =
|
||||
source of truth для рецепта.
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Лидерра тест-сервер — применить обновление (server-side половина).
|
||||
# ПЕРЕД запуском: с dev-машины залить новый код (git archive app db) + сборку
|
||||
# (app/public/build) через scp. Затем на сервере: bash /var/www/liderra/redeploy.sh
|
||||
set -euo pipefail
|
||||
cd /var/www/liderra/app
|
||||
composer install --optimize-autoloader --no-interaction --no-scripts --ignore-platform-req=ext-redis
|
||||
php artisan migrate --force
|
||||
sudo -u www-data php artisan optimize
|
||||
chmod -R a+rX public/build
|
||||
sudo chown -R ubuntu:www-data storage bootstrap/cache
|
||||
sudo chmod -R 775 storage bootstrap/cache
|
||||
sudo systemctl restart php8.3-fpm liderra-queue
|
||||
echo "Redeploy done at $(date -u +%FT%TZ)"
|
||||
@@ -201,6 +201,27 @@ export function buildEpisode({ state = null, transcriptText = null, ctx = {} } =
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user prompt for downstream consumers (self-assessment API,
|
||||
* embedding). Bug fix 2026-05-26: Claude Code's Stop-event stdin contract is
|
||||
* { session_id, transcript_path, stop_hook_active, hook_event_name } — it
|
||||
* never includes `prompt`. The real text lives in the transcript file. Prior
|
||||
* code blindly read `ctx.prompt`, so self-assessment always received "(пусто)"
|
||||
* and embedding was silently skipped. This helper prefers `ctx.prompt` (test
|
||||
* convenience) and falls back to extracting the last user message from the
|
||||
* transcript. Returns null when neither source has content.
|
||||
*/
|
||||
export function derivePrompt(ctx, transcriptText) {
|
||||
if (ctx && typeof ctx.prompt === 'string' && ctx.prompt.length > 0) {
|
||||
return ctx.prompt;
|
||||
}
|
||||
if (typeof transcriptText === 'string' && transcriptText.length > 0) {
|
||||
const text = extractLastUserPromptText(transcriptText);
|
||||
return text || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a self_assessment block (spec §4.5, Phase 3 Task 17). Pure.
|
||||
*
|
||||
@@ -372,6 +393,11 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s
|
||||
try {
|
||||
const ep = buildEpisodeFromContext(ctx, transcriptText);
|
||||
|
||||
// Bug fix 2026-05-26: resolve the real user prompt before calling
|
||||
// downstream consumers. ctx.prompt is never set by Stop-event stdin —
|
||||
// the prompt lives in the transcript. derivePrompt unifies the fallback.
|
||||
const userPrompt = derivePrompt(ctx, transcriptText);
|
||||
|
||||
// Step 3.5: self-assessment API call (fail-quiet).
|
||||
// Only runs when the runtime flag is 'on' and ROUTER_LLM_KEY is set.
|
||||
const saMode = readRuntimeFlag('self-assessment-mode');
|
||||
@@ -379,7 +405,7 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s
|
||||
if (saMode === 'on' && saApiKey) {
|
||||
const rat = ep.primary_rationale ?? {};
|
||||
const apiResult = await callSelfAssessmentApi({
|
||||
prompt: ctx.prompt || null,
|
||||
prompt: userPrompt,
|
||||
recommendedNode: rat.recommended_node || null,
|
||||
actualNode: rat.node_chosen || null,
|
||||
chainExecuted: rat.chain_executed || [],
|
||||
@@ -391,7 +417,7 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s
|
||||
// Step 3.6: embedding async wiring (fail-quiet, 2s timeout).
|
||||
// Trivial task types skipped via shouldEmbed. Mirrors Step 3.5 pattern.
|
||||
const embMode = readRuntimeFlag('embedding-mode');
|
||||
await computeEmbeddingForEpisode(ep, ctx, { embedMode: embMode });
|
||||
await computeEmbeddingForEpisode(ep, { ...ctx, prompt: userPrompt }, { embedMode: embMode });
|
||||
|
||||
// Always write the episode first — exit-0-safe (spec §5.1 step 1).
|
||||
appendEpisode(ep);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { appendEpisode, buildEpisodeFromContext, buildObserverError, routingGateDecision, buildExecutionTrace, buildEpisode, buildSelfAssessment, computeEmbeddingForEpisode } from './observer-stop-hook.mjs';
|
||||
import { appendEpisode, buildEpisodeFromContext, buildObserverError, routingGateDecision, buildExecutionTrace, buildEpisode, buildSelfAssessment, computeEmbeddingForEpisode, derivePrompt } from './observer-stop-hook.mjs';
|
||||
|
||||
let workdir;
|
||||
|
||||
@@ -366,3 +366,49 @@ describe('Step 3.6 embedding async wiring', () => {
|
||||
expect(ep.environment.embedding_unavailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// derivePrompt — Bug fix 2026-05-26: ctx.prompt is never set by Claude Code Stop
|
||||
// stdin (only session_id / transcript_path / stop_hook_active are sent). The
|
||||
// real user prompt lives in the transcript file. Self-assessment and embedding
|
||||
// both consumed ctx.prompt blindly → empty string passed to Sonnet ("(пусто)")
|
||||
// and embedding was silently skipped. derivePrompt unifies the fallback: prefer
|
||||
// ctx.prompt when present (e.g. tests), otherwise extract last user message
|
||||
// from transcriptText.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('derivePrompt — Stop-event prompt resolution', () => {
|
||||
const minimalTranscript = (text) =>
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
sessionId: 's1',
|
||||
timestamp: '2026-05-26T03:00:00Z',
|
||||
message: { role: 'user', content: text },
|
||||
}) + '\n';
|
||||
|
||||
it('returns ctx.prompt when explicitly provided (test path)', () => {
|
||||
expect(derivePrompt({ prompt: 'explicit' }, null)).toBe('explicit');
|
||||
});
|
||||
|
||||
it('extracts last user prompt from transcript when ctx.prompt missing (real Stop-event path)', () => {
|
||||
const transcript = minimalTranscript('реальный длинный запрос от заказчика');
|
||||
expect(derivePrompt({}, transcript)).toBe('реальный длинный запрос от заказчика');
|
||||
});
|
||||
|
||||
it('returns null when both ctx.prompt and transcriptText absent', () => {
|
||||
expect(derivePrompt({}, null)).toBeNull();
|
||||
expect(derivePrompt({}, '')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers ctx.prompt over transcript when both present', () => {
|
||||
const transcript = minimalTranscript('from transcript');
|
||||
expect(derivePrompt({ prompt: 'from ctx' }, transcript)).toBe('from ctx');
|
||||
});
|
||||
|
||||
it('handles ctx=null/undefined gracefully', () => {
|
||||
const transcript = minimalTranscript('из транскрипта');
|
||||
expect(derivePrompt(null, transcript)).toBe('из транскрипта');
|
||||
expect(derivePrompt(undefined, transcript)).toBe('из транскрипта');
|
||||
expect(derivePrompt(null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user