Skip to content

Commit 2cd4801

Browse files
committed
Add idempotent historical production import API
1 parent d4b85e4 commit 2cd4801

1 file changed

Lines changed: 58 additions & 0 deletions

File tree

‎api/historical-import.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {requireOperator} from './_auth.js'
2+
import {getSql} from './_db.js'
3+
4+
type Request={method?:string;headers?:Record<string,string|string[]|undefined>;body?:unknown}
5+
type Response={status:(code:number)=>Response;setHeader:(name:string,value:string)=>void;json:(body:unknown)=>void}
6+
type PayloadRow={sourceRow:number;recordStatus:'operational'|'void';eventDate:string|null;receptionDate:string|null;processDate:string|null;productionDate:string|null;guideNumber:string|null;supplierOriginal:string|null;supplierName:string|null;extractionZone:string|null;guidePriceClp:number|null;processSiteOriginal:string|null;lotCode:string;guideKg:number|null;receivedKg:number|null;differenceKg:number|null;qualityDiscount:number|null;gradeBreakdown:Record<string,unknown>;yields:Record<string,unknown>;client:string|null;observations:string|null;dataQualityFlags:string[];rawRecord:Record<string,unknown>}
7+
type Payload={fileName?:string;fileHash?:string;rows?:PayloadRow[]}
8+
9+
export default async function handler(request:Request,response:Response){
10+
response.setHeader('Cache-Control','no-store')
11+
if(request.method!=='POST'){response.setHeader('Allow','POST');return response.status(405).json({ok:false,error:'Método no permitido'})}
12+
try{
13+
const operator=await requireOperator(request,['admin','operations'])
14+
if(!operator)return response.status(403).json({ok:false,error:'Permisos insuficientes'})
15+
const body=(request.body??{}) as Payload
16+
const fileName=String(body.fileName??'').trim(),fileHash=String(body.fileHash??'').trim().toLowerCase(),rows=Array.isArray(body.rows)?body.rows:[]
17+
if(!fileName||!/^[a-f0-9]{64}$/.test(fileHash)||!rows.length)return response.status(400).json({ok:false,error:'Archivo, hash y filas válidas son obligatorios'})
18+
if(rows.length>10000)return response.status(413).json({ok:false,error:'La importación supera el máximo de 10.000 filas'})
19+
const clean=rows.filter(row=>Number.isInteger(row.sourceRow)&&row.sourceRow>0&&String(row.lotCode??'').trim()).map(row=>({...row,sourceFile:fileName,sourceFileHash:fileHash}))
20+
if(!clean.length)return response.status(400).json({ok:false,error:'No hay filas canónicas para publicar'})
21+
const sql=getSql()
22+
const before=await sql`select count(*)::int as count from historical_production_records where source_file_hash=${fileHash}`
23+
const existing=Number(Array.isArray(before)?before[0]?.count??0:0)
24+
await sql`
25+
insert into historical_production_records(
26+
source_row,source_file,source_file_hash,record_status,event_date,reception_date,process_date,production_date,
27+
guide_number,supplier_original,supplier_name,extraction_zone,guide_price_clp,process_site_original,lot_code,
28+
guide_kg,received_kg,difference_kg,quality_discount,grade_breakdown,yields,client,observations,data_quality_flags,raw_record
29+
)
30+
select
31+
x.source_row,${fileName},${fileHash},case when x.record_status='void' then 'void' else 'operational' end,
32+
nullif(x.event_date,'')::date,nullif(x.reception_date,'')::date,nullif(x.process_date,'')::date,nullif(x.production_date,'')::date,
33+
nullif(x.guide_number,''),nullif(x.supplier_original,''),nullif(x.supplier_name,''),nullif(x.extraction_zone,''),x.guide_price_clp,
34+
nullif(x.process_site_original,''),x.lot_code,x.guide_kg,x.received_kg,x.difference_kg,x.quality_discount,
35+
coalesce(x.grade_breakdown,'{}'::jsonb),coalesce(x.yields,'{}'::jsonb),nullif(x.client,''),nullif(x.observations,''),
36+
coalesce(x.data_quality_flags,array[]::text[]),coalesce(x.raw_record,'{}'::jsonb)
37+
from jsonb_to_recordset(${JSON.stringify(clean)}::jsonb) as x(
38+
source_row integer,record_status text,event_date text,reception_date text,process_date text,production_date text,
39+
guide_number text,supplier_original text,supplier_name text,extraction_zone text,guide_price_clp numeric,process_site_original text,lot_code text,
40+
guide_kg numeric,received_kg numeric,difference_kg numeric,quality_discount numeric,grade_breakdown jsonb,yields jsonb,client text,observations text,
41+
data_quality_flags text[],raw_record jsonb
42+
)
43+
on conflict(source_file_hash,source_row) do update set
44+
record_status=excluded.record_status,event_date=excluded.event_date,reception_date=excluded.reception_date,process_date=excluded.process_date,
45+
production_date=excluded.production_date,guide_number=excluded.guide_number,supplier_original=excluded.supplier_original,supplier_name=excluded.supplier_name,
46+
extraction_zone=excluded.extraction_zone,guide_price_clp=excluded.guide_price_clp,process_site_original=excluded.process_site_original,lot_code=excluded.lot_code,
47+
guide_kg=excluded.guide_kg,received_kg=excluded.received_kg,difference_kg=excluded.difference_kg,quality_discount=excluded.quality_discount,
48+
grade_breakdown=excluded.grade_breakdown,yields=excluded.yields,client=excluded.client,observations=excluded.observations,
49+
data_quality_flags=excluded.data_quality_flags,raw_record=excluded.raw_record
50+
`
51+
const after=await sql`select count(*)::int as count,count(*) filter(where cardinality(data_quality_flags)>0)::int as flagged from historical_production_records where source_file_hash=${fileHash}`
52+
const total=Number(Array.isArray(after)?after[0]?.count??0:0),flagged=Number(Array.isArray(after)?after[0]?.flagged??0:0)
53+
return response.status(200).json({ok:true,total,inserted:Math.max(0,total-existing),duplicates:Math.min(existing,total),flagged,fileHash})
54+
}catch(error){
55+
const message=error instanceof Error?error.message:''
56+
return response.status(message.includes('DATABASE_URL')?503:500).json({ok:false,error:message.includes('DATABASE_URL')?'Base de datos no conectada':'No fue posible publicar la producción histórica'})
57+
}
58+
}

0 commit comments

Comments
 (0)