@kansei-link/bantou 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/data/exclusion-rules/README.md +104 -0
- package/data/exclusion-rules/jp-tax-baseline-v1.json +185 -0
- package/data/exclusion-rules-schema.json +109 -0
- package/data/keyword-dict/README.md +91 -0
- package/data/keyword-dict/jp-tax-baseline-v1.json +398 -0
- package/data/keyword-dict-schema.json +117 -0
- package/data/tax-rules/jp-tax-rules-v1.json +170 -0
- package/dist/adapters/csv-parser.d.ts +11 -0
- package/dist/adapters/csv-parser.js +133 -0
- package/dist/adapters/freee-csv-adapter.d.ts +14 -0
- package/dist/adapters/freee-csv-adapter.js +67 -0
- package/dist/adapters/generic-adapter.d.ts +20 -0
- package/dist/adapters/generic-adapter.js +73 -0
- package/dist/adapters/index.d.ts +23 -0
- package/dist/adapters/index.js +386 -0
- package/dist/adapters/types.d.ts +111 -0
- package/dist/adapters/types.js +9 -0
- package/dist/adapters/yayoi-adapter.d.ts +46 -0
- package/dist/adapters/yayoi-adapter.js +181 -0
- package/dist/bin/freee-doctor.d.ts +3 -0
- package/dist/bin/freee-doctor.js +15 -0
- package/dist/classifier/claude-classifier.d.ts +24 -0
- package/dist/classifier/claude-classifier.js +154 -0
- package/dist/classifier/keyword-classifier.d.ts +22 -0
- package/dist/classifier/keyword-classifier.js +124 -0
- package/dist/classifier/keyword-match.d.ts +21 -0
- package/dist/classifier/keyword-match.js +57 -0
- package/dist/classifier/normalize.d.ts +3 -0
- package/dist/classifier/normalize.js +27 -0
- package/dist/classifier/two-stage-classifier.d.ts +21 -0
- package/dist/classifier/two-stage-classifier.js +51 -0
- package/dist/classifier/types.d.ts +31 -0
- package/dist/classifier/types.js +3 -0
- package/dist/connectors/freee.d.ts +115 -0
- package/dist/connectors/freee.js +177 -0
- package/dist/exclusion/exclusion-checker.d.ts +10 -0
- package/dist/exclusion/exclusion-checker.js +162 -0
- package/dist/freee-doctor.d.ts +26 -0
- package/dist/freee-doctor.js +82 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +656 -0
- package/dist/memory/cockpit-memory.d.ts +73 -0
- package/dist/memory/cockpit-memory.js +473 -0
- package/dist/memory/types.d.ts +114 -0
- package/dist/memory/types.js +11 -0
- package/dist/pipeline/confidence-router.d.ts +38 -0
- package/dist/pipeline/confidence-router.js +129 -0
- package/dist/pipeline/nightly-pipeline.d.ts +44 -0
- package/dist/pipeline/nightly-pipeline.js +497 -0
- package/dist/pipeline/types.d.ts +84 -0
- package/dist/pipeline/types.js +12 -0
- package/dist/reports/monthly-report.d.ts +64 -0
- package/dist/reports/monthly-report.js +230 -0
- package/dist/secrets.d.ts +14 -0
- package/dist/secrets.js +86 -0
- package/dist/tax-rules/tax-rule-engine.d.ts +103 -0
- package/dist/tax-rules/tax-rule-engine.js +449 -0
- package/dist/tax-rules/types.d.ts +103 -0
- package/dist/tax-rules/types.js +7 -0
- package/package.json +74 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
// Nightly batch pipeline orchestrator.
|
|
2
|
+
//
|
|
3
|
+
// Faithfully reproduces the 60-company nightly batch pattern:
|
|
4
|
+
//
|
|
5
|
+
// [21:00] Anthropic Routine → "今日の N 社 自動仕訳 batch を実行して"
|
|
6
|
+
// [21:00] list_companies() → N company IDs
|
|
7
|
+
// [21:01] Per company (concurrency-limited parallel):
|
|
8
|
+
// fetch_unprocessed → exclusion → classify → confidence route → (register)
|
|
9
|
+
// [21:50] Summary → Slack DM
|
|
10
|
+
//
|
|
11
|
+
// Key design decisions:
|
|
12
|
+
//
|
|
13
|
+
// 1. Concurrency limit (default 3) — freee API rate limit = 3600 req/h (= 60/min).
|
|
14
|
+
// With ~10 deals/company × ~2 API calls/deal, 3 concurrent companies stays safe.
|
|
15
|
+
//
|
|
16
|
+
// 2. Promise.allSettled per batch — one company failure doesn't kill the run.
|
|
17
|
+
// Error is captured in CompanyBatchResult, pipeline continues.
|
|
18
|
+
//
|
|
19
|
+
// 3. Memo extraction fallback chain — freee /deals list API quirk:
|
|
20
|
+
// memo || description || details[0].description || ref_number || ''
|
|
21
|
+
//
|
|
22
|
+
// 4. Partner detection — fetches existing partners per company, checks if
|
|
23
|
+
// deal.partner_name is in master. New partners → human_review.
|
|
24
|
+
//
|
|
25
|
+
// 5. Dry-run by default — Phase 1.A doesn't write back to freee.
|
|
26
|
+
// Write-back (freee.register_journal) deferred to Phase 1.B.
|
|
27
|
+
import { ConfidenceRouter } from './confidence-router.js';
|
|
28
|
+
const DEFAULT_CONFIG = {
|
|
29
|
+
dry_run: true,
|
|
30
|
+
concurrency: 3,
|
|
31
|
+
deals_per_company: 100,
|
|
32
|
+
};
|
|
33
|
+
// ============================================================
|
|
34
|
+
// Pipeline
|
|
35
|
+
// ============================================================
|
|
36
|
+
export class NightlyPipeline {
|
|
37
|
+
connector;
|
|
38
|
+
classifier;
|
|
39
|
+
exclusion;
|
|
40
|
+
router;
|
|
41
|
+
memory;
|
|
42
|
+
config;
|
|
43
|
+
constructor(connector, classifier, exclusion, config = {}, memory) {
|
|
44
|
+
this.connector = connector;
|
|
45
|
+
this.classifier = classifier;
|
|
46
|
+
this.exclusion = exclusion;
|
|
47
|
+
this.router = new ConfidenceRouter();
|
|
48
|
+
this.memory = memory || null;
|
|
49
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Run the full Full nightly batch.
|
|
53
|
+
*
|
|
54
|
+
* Pipeline:
|
|
55
|
+
* 1. List all accessible companies (or use override list)
|
|
56
|
+
* 2. For each company (concurrency-limited parallel):
|
|
57
|
+
* a. Fetch unprocessed deals (status = 'unsettled')
|
|
58
|
+
* b. Fetch existing partners (for new-partner detection)
|
|
59
|
+
* c. For each deal: exclusion → classify → route
|
|
60
|
+
* d. Aggregate per-company results
|
|
61
|
+
* 3. Aggregate all company results
|
|
62
|
+
* 4. Generate Batch summary (Slack-ready format)
|
|
63
|
+
*/
|
|
64
|
+
async run() {
|
|
65
|
+
const startedAt = new Date().toISOString();
|
|
66
|
+
// ── Step 1: Determine target companies ──────────────────────
|
|
67
|
+
let companies;
|
|
68
|
+
if (this.config.company_ids && this.config.company_ids.length > 0) {
|
|
69
|
+
// Explicit company list provided — create minimal objects
|
|
70
|
+
companies = this.config.company_ids.map(id => ({
|
|
71
|
+
id,
|
|
72
|
+
display_name: `Company ${id}`,
|
|
73
|
+
tax_at_source_calc_type: 0,
|
|
74
|
+
contact_name: '',
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// Discover all accessible companies from the token
|
|
79
|
+
try {
|
|
80
|
+
companies = await this.connector.listCompanies();
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
// Fallback: use default company from secrets (= single-company mode)
|
|
84
|
+
companies = [{
|
|
85
|
+
id: this.connector.companyId,
|
|
86
|
+
display_name: this.connector.companyName || `Company ${this.connector.companyId}`,
|
|
87
|
+
tax_at_source_calc_type: 0,
|
|
88
|
+
contact_name: '',
|
|
89
|
+
}];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// ── Step 2: Process companies with concurrency control ──────
|
|
93
|
+
const companyResults = [];
|
|
94
|
+
const batches = chunkArray(companies, this.config.concurrency);
|
|
95
|
+
for (const batch of batches) {
|
|
96
|
+
const settled = await Promise.allSettled(batch.map(company => this.processCompany(company)));
|
|
97
|
+
for (let i = 0; i < settled.length; i++) {
|
|
98
|
+
const result = settled[i];
|
|
99
|
+
if (result.status === 'fulfilled') {
|
|
100
|
+
companyResults.push(result.value);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// Entire company processing failed — capture error, continue
|
|
104
|
+
companyResults.push({
|
|
105
|
+
company_id: batch[i].id,
|
|
106
|
+
company_name: batch[i].display_name,
|
|
107
|
+
total_deals: 0,
|
|
108
|
+
processing_time_ms: 0,
|
|
109
|
+
ok: false,
|
|
110
|
+
error: result.reason?.message || String(result.reason),
|
|
111
|
+
summary: emptySummary(),
|
|
112
|
+
confidence_breakdown: { high: 0, medium: 0, low: 0, none: 0 },
|
|
113
|
+
review_queue: [],
|
|
114
|
+
sample: [],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// ── Step 3: Aggregate ──────────────────────────────────────
|
|
120
|
+
const aggregate = aggregateResults(companyResults);
|
|
121
|
+
const totalDeals = companyResults.reduce((sum, c) => sum + c.total_deals, 0);
|
|
122
|
+
const finishedAt = new Date().toISOString();
|
|
123
|
+
// ── Step 4: Build Slack-ready summary ──────────────────────
|
|
124
|
+
const memoryStats = this.memory?.getStats() || null;
|
|
125
|
+
const slackSummary = buildSlackSummary(companyResults, aggregate, startedAt, finishedAt, this.config.dry_run, memoryStats);
|
|
126
|
+
// ── Assemble final result ──────────────────────────────────
|
|
127
|
+
const s1 = this.classifier.getStage1();
|
|
128
|
+
const s2 = this.classifier.getStage2();
|
|
129
|
+
return {
|
|
130
|
+
ok: companyResults.some(c => c.ok), // OK if at least one company succeeded
|
|
131
|
+
dry_run: this.config.dry_run,
|
|
132
|
+
started_at: startedAt,
|
|
133
|
+
finished_at: finishedAt,
|
|
134
|
+
total_companies: companies.length,
|
|
135
|
+
total_deals: totalDeals,
|
|
136
|
+
aggregate,
|
|
137
|
+
companies: companyResults,
|
|
138
|
+
classifier: {
|
|
139
|
+
stage1_version: s1.getVersion(),
|
|
140
|
+
stage1_keywords: s1.getKeywordsCount(),
|
|
141
|
+
stage1_categories: s1.getCategoriesCount(),
|
|
142
|
+
stage2_enabled: this.classifier.hasStage2(),
|
|
143
|
+
stage2_model: s2?.getModel() || null,
|
|
144
|
+
},
|
|
145
|
+
exclusion_version: this.exclusion.getVersion(),
|
|
146
|
+
slack_summary: slackSummary,
|
|
147
|
+
note: this.config.dry_run
|
|
148
|
+
? 'Phase 1.A dry-run — write-back to freee pending Phase 1.B.'
|
|
149
|
+
: 'Live mode — transactions registered to freee.',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// ============================================================
|
|
153
|
+
// Per-company processing
|
|
154
|
+
// ============================================================
|
|
155
|
+
async processCompany(company) {
|
|
156
|
+
const t0 = Date.now();
|
|
157
|
+
const companyId = company.id;
|
|
158
|
+
// Fetch unprocessed deals (Step 3: status='unsettled' filter)
|
|
159
|
+
const deals = await this.connector.listDeals({
|
|
160
|
+
company_id: companyId,
|
|
161
|
+
status: 'unsettled',
|
|
162
|
+
start_issue_date: this.config.period_start,
|
|
163
|
+
end_issue_date: this.config.period_end,
|
|
164
|
+
limit: this.config.deals_per_company,
|
|
165
|
+
});
|
|
166
|
+
// Fetch existing partners for new-partner detection
|
|
167
|
+
// (Best-effort: if this fails, treat all partners as existing = safer)
|
|
168
|
+
let knownPartners;
|
|
169
|
+
try {
|
|
170
|
+
const partners = await this.connector.listPartners({
|
|
171
|
+
company_id: companyId,
|
|
172
|
+
limit: 500,
|
|
173
|
+
});
|
|
174
|
+
knownPartners = new Set(partners.map(p => p.name));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
knownPartners = new Set();
|
|
178
|
+
}
|
|
179
|
+
// Process each deal through the pipeline
|
|
180
|
+
const processed = [];
|
|
181
|
+
const summary = emptySummary();
|
|
182
|
+
const confidence = { high: 0, medium: 0, low: 0, none: 0 };
|
|
183
|
+
for (const deal of deals) {
|
|
184
|
+
try {
|
|
185
|
+
const pt = await this.processDeal(deal, companyId, knownPartners);
|
|
186
|
+
processed.push(pt);
|
|
187
|
+
// Update summary counters
|
|
188
|
+
if (pt.excluded) {
|
|
189
|
+
summary.excluded++;
|
|
190
|
+
}
|
|
191
|
+
else if (!pt.classified) {
|
|
192
|
+
summary.unclassified++;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
if (pt.stage === 1)
|
|
196
|
+
summary.classified_stage1++;
|
|
197
|
+
else if (pt.stage === 2)
|
|
198
|
+
summary.classified_stage2++;
|
|
199
|
+
}
|
|
200
|
+
// Confidence counter (including excluded = 'none')
|
|
201
|
+
const conf = pt.confidence || 'none';
|
|
202
|
+
if (conf in confidence)
|
|
203
|
+
confidence[conf]++;
|
|
204
|
+
// Routing action counter
|
|
205
|
+
switch (pt.routing.action) {
|
|
206
|
+
case 'auto_register':
|
|
207
|
+
summary.auto_registered++;
|
|
208
|
+
break;
|
|
209
|
+
case 'auto_register_with_log':
|
|
210
|
+
summary.auto_registered_with_log++;
|
|
211
|
+
break;
|
|
212
|
+
case 'human_review':
|
|
213
|
+
summary.human_review++;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
summary.errors++;
|
|
219
|
+
processed.push(makeErrorTransaction(deal, companyId, err?.message || String(err)));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Build review queue: all items that need human attention
|
|
223
|
+
const reviewQueue = processed.filter(p => p.routing.action === 'human_review'
|
|
224
|
+
|| p.routing.action === 'auto_register_with_log');
|
|
225
|
+
// Sample: first 5 transactions for summary display
|
|
226
|
+
const sample = processed.slice(0, 5);
|
|
227
|
+
// Save memory after processing each company (batch-level persistence)
|
|
228
|
+
if (this.memory) {
|
|
229
|
+
this.memory.save();
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
company_id: companyId,
|
|
233
|
+
company_name: company.display_name,
|
|
234
|
+
total_deals: deals.length,
|
|
235
|
+
processing_time_ms: Date.now() - t0,
|
|
236
|
+
ok: summary.errors === 0,
|
|
237
|
+
summary,
|
|
238
|
+
confidence_breakdown: confidence,
|
|
239
|
+
review_queue: reviewQueue,
|
|
240
|
+
sample,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// ============================================================
|
|
244
|
+
// Per-deal processing
|
|
245
|
+
// ============================================================
|
|
246
|
+
async processDeal(deal, companyId, knownPartners) {
|
|
247
|
+
// Extract memo (freee API quirk: details[0].description is the real memo)
|
|
248
|
+
const memo = deal.memo
|
|
249
|
+
|| deal.description
|
|
250
|
+
|| (deal.details?.[0]?.description)
|
|
251
|
+
|| deal.ref_number
|
|
252
|
+
|| '';
|
|
253
|
+
const tx = {
|
|
254
|
+
amount: deal.amount,
|
|
255
|
+
memo,
|
|
256
|
+
date: deal.issue_date,
|
|
257
|
+
partner_name: deal.partner_name,
|
|
258
|
+
company_id: companyId,
|
|
259
|
+
};
|
|
260
|
+
// ── Stage 0: Exclusion check ──
|
|
261
|
+
const exc = this.exclusion.check(tx);
|
|
262
|
+
if (exc.excluded) {
|
|
263
|
+
const routing = this.router.route(exc, null, {
|
|
264
|
+
amount: deal.amount,
|
|
265
|
+
partner_name: deal.partner_name,
|
|
266
|
+
is_new_partner: false,
|
|
267
|
+
date: deal.issue_date,
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
deal_id: deal.id,
|
|
271
|
+
company_id: companyId,
|
|
272
|
+
issue_date: deal.issue_date,
|
|
273
|
+
amount: deal.amount,
|
|
274
|
+
memo,
|
|
275
|
+
partner_name: deal.partner_name,
|
|
276
|
+
excluded: true,
|
|
277
|
+
exclusion_rule: exc.rule_id,
|
|
278
|
+
exclusion_reason: exc.reason,
|
|
279
|
+
classified: false,
|
|
280
|
+
confidence: 'none',
|
|
281
|
+
routing,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
// ── Memory recall: check past patterns before classification ──
|
|
285
|
+
//
|
|
286
|
+
// Rule: "過去パターン一致 → AI 処理 OK"
|
|
287
|
+
// If memory finds a correction or pattern, skip classification entirely.
|
|
288
|
+
if (this.memory) {
|
|
289
|
+
const recall = this.memory.recallPattern(tx);
|
|
290
|
+
if (recall.found && recall.source === 'correction' && recall.correction) {
|
|
291
|
+
// Correction hit — use corrected category, always high confidence
|
|
292
|
+
const isNewPartner = Boolean(deal.partner_name && deal.partner_name.trim() !== ''
|
|
293
|
+
&& !knownPartners.has(deal.partner_name));
|
|
294
|
+
const routing = this.router.route({ excluded: false }, {
|
|
295
|
+
classified: true,
|
|
296
|
+
category_id: recall.correction.to_category_id,
|
|
297
|
+
category_name_ja: recall.correction.to_category_name_ja,
|
|
298
|
+
freee_account_code: recall.correction.to_freee_account_code,
|
|
299
|
+
tax_code: recall.correction.to_tax_code,
|
|
300
|
+
confidence: 'high',
|
|
301
|
+
match_reason: recall.reason,
|
|
302
|
+
classifier_version: 'memory-correction',
|
|
303
|
+
}, { amount: deal.amount, partner_name: deal.partner_name, is_new_partner: isNewPartner, date: deal.issue_date });
|
|
304
|
+
return {
|
|
305
|
+
deal_id: deal.id,
|
|
306
|
+
company_id: companyId,
|
|
307
|
+
issue_date: deal.issue_date,
|
|
308
|
+
amount: deal.amount,
|
|
309
|
+
memo,
|
|
310
|
+
partner_name: deal.partner_name,
|
|
311
|
+
excluded: false,
|
|
312
|
+
classified: true,
|
|
313
|
+
category_id: recall.correction.to_category_id,
|
|
314
|
+
category_name_ja: recall.correction.to_category_name_ja,
|
|
315
|
+
confidence: 'high',
|
|
316
|
+
memory_source: 'correction',
|
|
317
|
+
memory_pattern_key: recall.correction.partner_key,
|
|
318
|
+
routing,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (recall.found && recall.source === 'pattern' && recall.pattern) {
|
|
322
|
+
// Pattern hit — use remembered classification
|
|
323
|
+
const isNewPartner = Boolean(deal.partner_name && deal.partner_name.trim() !== ''
|
|
324
|
+
&& !knownPartners.has(deal.partner_name));
|
|
325
|
+
const routing = this.router.route({ excluded: false }, {
|
|
326
|
+
classified: true,
|
|
327
|
+
category_id: recall.pattern.category_id,
|
|
328
|
+
category_name_ja: recall.pattern.category_name_ja,
|
|
329
|
+
freee_account_code: recall.pattern.freee_account_code,
|
|
330
|
+
tax_code: recall.pattern.tax_code,
|
|
331
|
+
confidence: recall.confidence,
|
|
332
|
+
match_reason: recall.reason,
|
|
333
|
+
classifier_version: 'memory-pattern',
|
|
334
|
+
}, { amount: deal.amount, partner_name: deal.partner_name, is_new_partner: isNewPartner, date: deal.issue_date });
|
|
335
|
+
// Update pattern match count
|
|
336
|
+
recall.pattern.match_count++;
|
|
337
|
+
recall.pattern.last_seen = new Date().toISOString().slice(0, 10);
|
|
338
|
+
return {
|
|
339
|
+
deal_id: deal.id,
|
|
340
|
+
company_id: companyId,
|
|
341
|
+
issue_date: deal.issue_date,
|
|
342
|
+
amount: deal.amount,
|
|
343
|
+
memo,
|
|
344
|
+
partner_name: deal.partner_name,
|
|
345
|
+
excluded: false,
|
|
346
|
+
classified: true,
|
|
347
|
+
category_id: recall.pattern.category_id,
|
|
348
|
+
category_name_ja: recall.pattern.category_name_ja,
|
|
349
|
+
confidence: recall.confidence,
|
|
350
|
+
memory_source: 'pattern',
|
|
351
|
+
memory_pattern_key: recall.pattern.partner_key,
|
|
352
|
+
memory_match_count: recall.pattern.match_count,
|
|
353
|
+
routing,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// ── Stage 1 + 2: Two-stage classification (memory miss) ──
|
|
358
|
+
const cls = await this.classifier.classify(tx);
|
|
359
|
+
// ── New partner detection ──
|
|
360
|
+
const isNewPartner = Boolean(deal.partner_name && deal.partner_name.trim() !== ''
|
|
361
|
+
&& !knownPartners.has(deal.partner_name));
|
|
362
|
+
// ── Confidence routing ──
|
|
363
|
+
const routingContext = {
|
|
364
|
+
amount: deal.amount,
|
|
365
|
+
partner_name: deal.partner_name,
|
|
366
|
+
is_new_partner: isNewPartner,
|
|
367
|
+
date: deal.issue_date,
|
|
368
|
+
};
|
|
369
|
+
const routing = this.router.route({ excluded: false }, cls, routingContext);
|
|
370
|
+
// ── Remember classification for future recall ──
|
|
371
|
+
//
|
|
372
|
+
// Only remember successful classifications (auto_register / auto_register_with_log).
|
|
373
|
+
// Don't remember human_review items — those need human judgment first.
|
|
374
|
+
if (this.memory && cls.classified && (routing.action === 'auto_register' || routing.action === 'auto_register_with_log')) {
|
|
375
|
+
this.memory.rememberClassification(tx, cls.category_id, cls.category_name_ja, cls.confidence, cls.stage === 1 ? 'keyword' : 'claude', cls.freee_account_code, typeof cls.tax_code === 'number' ? cls.tax_code : undefined);
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
deal_id: deal.id,
|
|
379
|
+
company_id: companyId,
|
|
380
|
+
issue_date: deal.issue_date,
|
|
381
|
+
amount: deal.amount,
|
|
382
|
+
memo,
|
|
383
|
+
partner_name: deal.partner_name,
|
|
384
|
+
excluded: false,
|
|
385
|
+
classified: cls.classified,
|
|
386
|
+
stage: cls.stage === 'unclassified' ? undefined : cls.stage,
|
|
387
|
+
category_id: cls.category_id,
|
|
388
|
+
category_name_ja: cls.category_name_ja,
|
|
389
|
+
confidence: cls.confidence,
|
|
390
|
+
matched_keyword: cls.matched_keyword,
|
|
391
|
+
routing,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ============================================================
|
|
396
|
+
// Pure helper functions (no state)
|
|
397
|
+
// ============================================================
|
|
398
|
+
function emptySummary() {
|
|
399
|
+
return {
|
|
400
|
+
auto_registered: 0,
|
|
401
|
+
auto_registered_with_log: 0,
|
|
402
|
+
human_review: 0,
|
|
403
|
+
excluded: 0,
|
|
404
|
+
classified_stage1: 0,
|
|
405
|
+
classified_stage2: 0,
|
|
406
|
+
unclassified: 0,
|
|
407
|
+
errors: 0,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function aggregateResults(companies) {
|
|
411
|
+
const agg = emptySummary();
|
|
412
|
+
for (const c of companies) {
|
|
413
|
+
agg.auto_registered += c.summary.auto_registered;
|
|
414
|
+
agg.auto_registered_with_log += c.summary.auto_registered_with_log;
|
|
415
|
+
agg.human_review += c.summary.human_review;
|
|
416
|
+
agg.excluded += c.summary.excluded;
|
|
417
|
+
agg.classified_stage1 += c.summary.classified_stage1;
|
|
418
|
+
agg.classified_stage2 += c.summary.classified_stage2;
|
|
419
|
+
agg.unclassified += c.summary.unclassified;
|
|
420
|
+
agg.errors += c.summary.errors;
|
|
421
|
+
}
|
|
422
|
+
return agg;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Build Batch summary (Slack-ready format).
|
|
426
|
+
*
|
|
427
|
+
* Target format (practitioner-proven Slack notification):
|
|
428
|
+
* Cockpit nightly run 完了 (21:50 JST):
|
|
429
|
+
* - 全 60 社 / 計 750 件 処理
|
|
430
|
+
* - 自動 register: 712 件 (high/medium confidence)
|
|
431
|
+
* - 確認待ち: 38 件
|
|
432
|
+
* - エラー: 0 件
|
|
433
|
+
* - 処理時間: 50 分
|
|
434
|
+
*/
|
|
435
|
+
function buildSlackSummary(companies, aggregate, startedAt, finishedAt, dryRun, memoryStats) {
|
|
436
|
+
const totalDeals = companies.reduce((s, c) => s + c.total_deals, 0);
|
|
437
|
+
const autoTotal = aggregate.auto_registered + aggregate.auto_registered_with_log;
|
|
438
|
+
const elapsedMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
439
|
+
const elapsedSec = (elapsedMs / 1000).toFixed(1);
|
|
440
|
+
const elapsedMin = (elapsedMs / 60000).toFixed(1);
|
|
441
|
+
const elapsedDisplay = elapsedMs >= 60000 ? `${elapsedMin} 分` : `${elapsedSec} 秒`;
|
|
442
|
+
// JST timestamp for display
|
|
443
|
+
const finishedJST = new Date(new Date(finishedAt).getTime() + 9 * 3600 * 1000)
|
|
444
|
+
.toISOString().slice(11, 16);
|
|
445
|
+
const lines = [
|
|
446
|
+
`Cockpit nightly run 完了 (${finishedJST} JST):`,
|
|
447
|
+
`- 全 ${companies.length} 社 / 計 ${totalDeals} 件 処理`,
|
|
448
|
+
`- 自動 register: ${autoTotal} 件 (high: ${aggregate.auto_registered}, medium+log: ${aggregate.auto_registered_with_log})`,
|
|
449
|
+
`- 確認待ち: ${aggregate.human_review} 件 (low + exclusion + 高額 + 新規取引先)`,
|
|
450
|
+
`- 除外: ${aggregate.excluded} 件 (Stage 0 exclusion)`,
|
|
451
|
+
`- 分類: Stage 1 = ${aggregate.classified_stage1} 件, Stage 2 = ${aggregate.classified_stage2} 件, 未分類 = ${aggregate.unclassified} 件`,
|
|
452
|
+
`- エラー: ${aggregate.errors} 件`,
|
|
453
|
+
`- 処理時間: ${elapsedDisplay}`,
|
|
454
|
+
];
|
|
455
|
+
// Memory stats (if available)
|
|
456
|
+
if (memoryStats) {
|
|
457
|
+
const memTotal = memoryStats.pattern_hits + memoryStats.correction_hits + memoryStats.cache_misses;
|
|
458
|
+
if (memTotal > 0) {
|
|
459
|
+
lines.push(`- Memory: pattern hit ${memoryStats.pattern_hits} / correction hit ${memoryStats.correction_hits} / miss ${memoryStats.cache_misses}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (dryRun) {
|
|
463
|
+
lines.push('- [DRY RUN] freee への書き込みは行っていません');
|
|
464
|
+
}
|
|
465
|
+
// Report failed companies
|
|
466
|
+
const failed = companies.filter(c => !c.ok);
|
|
467
|
+
if (failed.length > 0) {
|
|
468
|
+
lines.push(`- 失敗: ${failed.map(c => `${c.company_name} (${c.error?.slice(0, 60) || 'unknown'})`).join(', ')}`);
|
|
469
|
+
}
|
|
470
|
+
return lines.join('\n');
|
|
471
|
+
}
|
|
472
|
+
function makeErrorTransaction(deal, companyId, error) {
|
|
473
|
+
return {
|
|
474
|
+
deal_id: deal.id,
|
|
475
|
+
company_id: companyId,
|
|
476
|
+
issue_date: deal.issue_date,
|
|
477
|
+
amount: deal.amount,
|
|
478
|
+
memo: deal.memo || deal.description || '',
|
|
479
|
+
partner_name: deal.partner_name,
|
|
480
|
+
excluded: false,
|
|
481
|
+
classified: false,
|
|
482
|
+
confidence: 'none',
|
|
483
|
+
routing: {
|
|
484
|
+
action: 'human_review',
|
|
485
|
+
reasons: [`Pipeline error: ${error}`],
|
|
486
|
+
flags: [],
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
function chunkArray(arr, size) {
|
|
491
|
+
const chunks = [];
|
|
492
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
493
|
+
chunks.push(arr.slice(i, i + size));
|
|
494
|
+
}
|
|
495
|
+
return chunks;
|
|
496
|
+
}
|
|
497
|
+
//# sourceMappingURL=nightly-pipeline.js.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export type RoutingAction = 'auto_register' | 'auto_register_with_log' | 'human_review';
|
|
2
|
+
export type RoutingFlag = 'high_amount' | 'new_partner' | 'low_confidence' | 'medium_confidence' | 'excluded' | 'unclassified' | 'monthly_close_period';
|
|
3
|
+
export interface RoutingDecision {
|
|
4
|
+
action: RoutingAction;
|
|
5
|
+
reasons: string[];
|
|
6
|
+
flags: RoutingFlag[];
|
|
7
|
+
}
|
|
8
|
+
export interface ProcessedTransaction {
|
|
9
|
+
deal_id: number;
|
|
10
|
+
company_id: number;
|
|
11
|
+
issue_date: string;
|
|
12
|
+
amount: number;
|
|
13
|
+
memo: string;
|
|
14
|
+
partner_name?: string;
|
|
15
|
+
excluded: boolean;
|
|
16
|
+
exclusion_rule?: string;
|
|
17
|
+
exclusion_reason?: string;
|
|
18
|
+
classified: boolean;
|
|
19
|
+
stage?: 1 | 2;
|
|
20
|
+
category_id?: string;
|
|
21
|
+
category_name_ja?: string;
|
|
22
|
+
confidence?: 'high' | 'medium' | 'low' | 'none';
|
|
23
|
+
matched_keyword?: string;
|
|
24
|
+
memory_source?: 'pattern' | 'correction';
|
|
25
|
+
memory_pattern_key?: string;
|
|
26
|
+
memory_match_count?: number;
|
|
27
|
+
routing: RoutingDecision;
|
|
28
|
+
}
|
|
29
|
+
export interface CompanyBatchResult {
|
|
30
|
+
company_id: number;
|
|
31
|
+
company_name: string;
|
|
32
|
+
total_deals: number;
|
|
33
|
+
processing_time_ms: number;
|
|
34
|
+
ok: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
summary: {
|
|
37
|
+
auto_registered: number;
|
|
38
|
+
auto_registered_with_log: number;
|
|
39
|
+
human_review: number;
|
|
40
|
+
excluded: number;
|
|
41
|
+
classified_stage1: number;
|
|
42
|
+
classified_stage2: number;
|
|
43
|
+
unclassified: number;
|
|
44
|
+
errors: number;
|
|
45
|
+
};
|
|
46
|
+
confidence_breakdown: {
|
|
47
|
+
high: number;
|
|
48
|
+
medium: number;
|
|
49
|
+
low: number;
|
|
50
|
+
none: number;
|
|
51
|
+
};
|
|
52
|
+
review_queue: ProcessedTransaction[];
|
|
53
|
+
sample: ProcessedTransaction[];
|
|
54
|
+
}
|
|
55
|
+
export interface NightlyRunResult {
|
|
56
|
+
ok: boolean;
|
|
57
|
+
dry_run: boolean;
|
|
58
|
+
started_at: string;
|
|
59
|
+
finished_at: string;
|
|
60
|
+
total_companies: number;
|
|
61
|
+
total_deals: number;
|
|
62
|
+
aggregate: {
|
|
63
|
+
auto_registered: number;
|
|
64
|
+
auto_registered_with_log: number;
|
|
65
|
+
human_review: number;
|
|
66
|
+
excluded: number;
|
|
67
|
+
classified_stage1: number;
|
|
68
|
+
classified_stage2: number;
|
|
69
|
+
unclassified: number;
|
|
70
|
+
errors: number;
|
|
71
|
+
};
|
|
72
|
+
companies: CompanyBatchResult[];
|
|
73
|
+
classifier: {
|
|
74
|
+
stage1_version: string;
|
|
75
|
+
stage1_keywords: number;
|
|
76
|
+
stage1_categories: number;
|
|
77
|
+
stage2_enabled: boolean;
|
|
78
|
+
stage2_model: string | null;
|
|
79
|
+
};
|
|
80
|
+
exclusion_version: string;
|
|
81
|
+
slack_summary: string;
|
|
82
|
+
note: string;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Pipeline result types for the nightly batch processor.
|
|
2
|
+
//
|
|
3
|
+
// These types represent the full lifecycle of a transaction through the pipeline:
|
|
4
|
+
// Stage 0 (exclusion) → Stage 1 (keyword) → Stage 2 (Claude) → Confidence routing → Action
|
|
5
|
+
//
|
|
6
|
+
// Routing decisions follow the CLAUDE.md business manual:
|
|
7
|
+
// high confidence + no flags → auto_register
|
|
8
|
+
// medium confidence → auto_register_with_log (= 自動仕訳 + 確認待ちmirror)
|
|
9
|
+
// low confidence → human_review only
|
|
10
|
+
// 100万円超 / 新規取引先 / 月次決算期間 → human_review regardless
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Transaction } from '../classifier/types.js';
|
|
2
|
+
import { TwoStageClassifier } from '../classifier/two-stage-classifier.js';
|
|
3
|
+
import { ExclusionChecker } from '../exclusion/exclusion-checker.js';
|
|
4
|
+
import { ConfidenceRouter } from '../pipeline/confidence-router.js';
|
|
5
|
+
/** Processed transaction with classification for reporting. */
|
|
6
|
+
interface ReportTransaction {
|
|
7
|
+
transaction: Transaction;
|
|
8
|
+
category_id?: string;
|
|
9
|
+
category_name_ja?: string;
|
|
10
|
+
confidence?: string;
|
|
11
|
+
excluded: boolean;
|
|
12
|
+
exclusion_rule?: string;
|
|
13
|
+
action: string;
|
|
14
|
+
flags: string[];
|
|
15
|
+
}
|
|
16
|
+
/** Category aggregate for the report. */
|
|
17
|
+
interface CategoryAggregate {
|
|
18
|
+
category_id: string;
|
|
19
|
+
category_name_ja: string;
|
|
20
|
+
count: number;
|
|
21
|
+
total_amount: number;
|
|
22
|
+
transactions: ReportTransaction[];
|
|
23
|
+
}
|
|
24
|
+
/** Anomaly detected in the data. */
|
|
25
|
+
interface Anomaly {
|
|
26
|
+
type: 'high_amount' | 'unusual_category' | 'new_partner' | 'frequency_spike';
|
|
27
|
+
severity: 'warning' | 'critical';
|
|
28
|
+
description: string;
|
|
29
|
+
details: string;
|
|
30
|
+
}
|
|
31
|
+
/** Monthly report output. */
|
|
32
|
+
export interface MonthlyReportResult {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
company_name?: string;
|
|
35
|
+
month: string;
|
|
36
|
+
format: 'markdown' | 'json';
|
|
37
|
+
total_transactions: number;
|
|
38
|
+
total_expense: number;
|
|
39
|
+
total_income: number;
|
|
40
|
+
classification_rate: string;
|
|
41
|
+
auto_register_rate: string;
|
|
42
|
+
categories: CategoryAggregate[];
|
|
43
|
+
anomalies: Anomaly[];
|
|
44
|
+
review_items: ReportTransaction[];
|
|
45
|
+
markdown?: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Generate a monthly report from raw transactions.
|
|
49
|
+
*
|
|
50
|
+
* @param transactions - Array of raw transactions (from freee API, CSV import, etc.)
|
|
51
|
+
* @param classifier - TwoStageClassifier instance.
|
|
52
|
+
* @param exclusion - ExclusionChecker instance.
|
|
53
|
+
* @param router - ConfidenceRouter instance.
|
|
54
|
+
* @param opts - Report options.
|
|
55
|
+
*/
|
|
56
|
+
export declare function generateMonthlyReport(transactions: Transaction[], classifier: TwoStageClassifier, exclusion: ExclusionChecker, router: ConfidenceRouter, opts: {
|
|
57
|
+
company_name?: string;
|
|
58
|
+
month: string;
|
|
59
|
+
compare_transactions?: Transaction[];
|
|
60
|
+
compare_label?: string;
|
|
61
|
+
format?: 'markdown' | 'json';
|
|
62
|
+
}): Promise<MonthlyReportResult>;
|
|
63
|
+
export {};
|
|
64
|
+
//# sourceMappingURL=monthly-report.d.ts.map
|