Appearance
ChatGPT Ads feed — decisions and operation
Logidav owns generation and SFTP delivery of the French Menzzo product catalog for chatgptads feed (identifier supplied through approved deployment configuration). Delivery tracking remains open until production generation, transfer and OpenAI acceptance are verified.
Required marks an owner instruction, Chosen an implementation decision, and Open an unresolved deployment or acceptance boundary.
Ownership and contract
The merged implementation moved ownership from menzzo-partners to a Logidav Symfony console command. Configuration belongs in app/config/parameters.yml. SFTP is the delivery method; the former /feed/chatgptads HTTP endpoint and partners cron were removed. No public Logidav route or database migration was added. Campaign creation, activation, ad spending, and account checkout configuration are outside this page.
Chosen: menzzo:chatgptads:export obtains rows through the existing ProductService and ProductRepository, delegates CSV mapping to ChatgptAdsFeedExporter, and delegates transfer to ChatgptAdsSftpUploader. Reuse installed phpseclib 2 and Symfony 3; Composer requirements and the lock file do not change. The command returns 0 on success and 1 on failure. Keep the mapping once: named row keys produce the CSV header.
View diagram source
flowchart LR
DB[Logidav catalog] --> Command[Console command]
Command --> CSV[Validated local CSV]
CSV --> SFTP[OpenAI SFTP]
SFTP --> History[OpenAI Upload History]
Required: Canonical documentation stays in the shared docs submodule under content/projects/logidav/integrations. This record uses direct decisions, without an interactive questionnaire. Application PRs pin the documentation commit.
File format
Chosen: Use the native OpenAI product format: comma-delimited UTF-8 CSV with a header and one row per item. Keep French accents; quote commas and embedded quotes by quoting every field, doubling embedded double quotes and writing CRLF records with SplFileObject::fwrite. Check the full byte count, including short writes. This avoids the legacy fputcsv backslash mechanism and its PHP 7.4-only empty escape argument, which failed on the deployed runtime. Do not reuse the older pipe-delimited/Latin-1 response format or the Google-compatible field aliases. The generator uses PHP 7.3-era features (JSON_THROW_ON_ERROR); CSV serialization no longer requires PHP 7.4. The migrated implementation is tested on PHP 7.3.33; deployed generation must still be verified.
Eligibility
Current source: Send is_ads_eligible=true, is_eligible_search=true and is_eligible_checkout=true on every exported row. This opts listings into search and checkout processing when the account integrations are enabled; it does not prove they will be accepted, displayed, or purchasable. Checkout enablement, policies, and owner acceptance remain an unresolved rollout gate. The optional ads_metadata field carries the existing Google Merchant custom labels for Ads product-set filters; OpenAI must enable the corresponding keys for this feed.
Catalog and product decisions
Market and currency
Chosen: Read French store 3 and preserve EUR prices. Do not relabel amounts as USD or add country columns that suggest market support is enabled. Open: Confirm France/EUR support for the actual OpenAI account/feed before importing. The product schema describes a US default and requires setup for extra markets; the Ads guide's US/USD examples are not evidence that this French catalog is supported.
Catalog selection
Chosen: Keep the existing catalog gates, with French store-effective visibility restricted to 2,4 using COALESCE(pi.visibility, p.visibility), matching Google Shopping. A null store-3 visibility falls back to the global value. Export only active products with a positive source_id, is_new=0, a non-null/nonempty p.ean, and product-information records in stores 3, 8 and 9. Chosen: Include dropship suppliers; drop_ship no longer restricts eligibility. Join store 3 for French attributes; use EXISTS for stores 8 and 9 so auxiliary records cannot multiply exported rows.
Require p.source_id IS NOT NULL AND p.source_id > 0: Logidav stores this as a nullable integer. This strengthens the general feed's legacy non-null check, which can admit zero or negative IDs. Keep pi.feed_id as the exported stable item identity.
The EAN requirement is an existing Menzzo catalog rule, not an OpenAI requirement. Only checksum-valid pi.ean values populate the optional gtin column. Exclude products whose JSON p.tags contains the exact nosell or reservation tag. The reservation sync maintains both tags, and Google Shopping uses nosell for customLabel3. Null/empty tags and unrelated tags remain eligible. Stock quantity and future lead time do not remove an otherwise eligible product. Do not add a row limit or an in-stock-only filter to force an approximate target count.
The former French-store-only query broadened the catalog substantially. Compare regenerated IDs/counts against the general feed; stricter partner exclusions, required field validation can make the Ads feed smaller. Cache freshness and upstream data must be considered when comparing snapshots.
Exclusions
Chosen: Preserve the existing partner SKU/EAN exclusion lists in the Logidav repository query as feed-specific constants. Apply normalized exclusions through bound DBAL array parameters. This preserves the stricter partner scope without a runtime dependency on menzzo-partners; future list changes must be coordinated between owners. Other Logidav exports and partners feeds are unchanged.
Identity
Chosen: Use pi.feed_id as a stable string, retaining leading zeros when provided. Do not derive IDs from prices, row positions or titles. A missing ID skips the row. Duplicate IDs among valid rows fail generation and preserve the previous CSV, rather than choosing an arbitrary winner. Emit only item_id as listing identity; omit the optional offer_id and parent/variant fields. A promotion never changes the item ID.
Chosen: Export every product as an independent listing by explicit owner decision. Keep the raw pi.feed_id as item_id. Omit offer_id, group_id, variant_dict and listing_has_variations entirely; standalone products do not require them. The synchronized menzzo_parent_id relationship represents related products and must not create variants in this feed. Products with matching color/material/size remain separate listings and are not rejected together. Ordinary row validation and duplicate item-ID protection still apply.
Column mapping
Chosen: Export the following fixed schema. No fields beyond this table are currently emitted.
| Column | Source or decision |
|---|---|
item_id | pi.feed_id |
gtin | pi.ean, only 8/12/13/14 digits with valid checksum; preserve leading zeros |
title | pi.name, plain text, at most 150 characters |
description | pi.description plus pi.short_description, plain text, at most 5,000 characters |
url | pi.url, validated HTTPS URL; use Google Shopping's Tools::stripQueryParamFromUrl helper to remove query strings and fragments; skip uncrawled catalog/product/view URLs |
brand, seller_name | MENZZO, matching the existing Facebook feed convention |
image_url | First element of decoded pi.photos, validated HTTPS URL |
additional_image_urls | Remaining valid HTTPS photos, comma-separated; literal URL commas encoded as %2C |
color | Plain-text pi.color |
material | Sorted, distinct string labels from JSON pi.material_detail, joined with / |
size | Plain-text pi.dimension product size label, not package measurements |
availability | Stock rule |
price | pi.price, two decimal places followed by EUR |
sale_price | Valid current pi.special_price, otherwise empty |
condition | new |
is_ads_eligible | true |
is_eligible_search, is_eligible_checkout | false |
ads_metadata | JSON object with nonempty custom_label_0 through custom_label_4, using the Google Merchant automatic labels from mz_product and the same manual override or clear values |
Each product stays on one CSV row even when it has several photos. Keep the first photo as the main image and export all remaining valid HTTPS photos together in the quoted additional_image_urls cell, as supported by OpenAI's product schema.
HTML entities are decoded, tags/control characters become spaces and whitespace is collapsed. UTF-8 character limits apply after normalization. Brand, seller and condition are channel conventions, not newly verified per-product facts; compare them with actual product pages during acceptance.
Row validation
Chosen: Skip rows with an empty ID, title or description, an invalid/non-HTTPS product URL or first image, or a nonpositive/non-numeric/nonfinite price. A price that rounds to zero is invalid. Do not substitute a different image or fabricate missing text. Successful generation logs the number of skipped rows. Group rejection warnings include identifiers and the reason, without descriptions or other product contents. URL validation checks syntax and HTTPS only; it does not fetch the page or prove that OpenAI can access it. Open: Validate production row counts and rejection causes before using this catalog.
Prices and promotions
Chosen: Format numeric prices to two decimal places in EUR. Source values may have four decimals; output is rounded to cents, so confirm agreement with payable storefront prices on representative products. Include a sale only when it is positive, lower than the formatted regular price, and both date boundaries are valid and cover the calendar day supplied by the command. The command uses the server clock; configure the scheduler/server timezone as Europe/Paris, following the Symfony 3 rules. The exporter does not convert timezones. Start/end days are inclusive; timestamp time components are not used for intraday scheduling. Missing boundaries, malformed dates, future sales and expired sales omit sale_price. Regeneration is required when a sale starts or ends; OpenAI cannot derive future changes from this file.
Stock
Chosen: Emit in_stock for every currently eligible product, matching the French Google Shopping feed. Reservation products are excluded through the nosell/reservation tags before mapping. Future availability and zero or negative quantity do not change this value; it does not calculate physical stock or introduce backorder.
Chosen: Compare each regenerated catalog with the prior local CSV by exact item_id. A previously exported item missing from the new valid catalog stays in the CSV with its prior fields and availability=out_of_stock. Keep it for 48 hours from its first successful SFTP transfer in that state, then omit it. If the item returns, the fresh in_stock row wins and its retirement clock clears after upload. Empty or wholly invalid source catalogs still fail generation rather than marking every item out of stock.
The timestamp is held beside the CSV in <output>.out_of_stock.json; preserve both files across deployments and restore them together. --no-upload and failed SFTP transfers do not start the clock. The first run can read the previous standalone CSV with the four retired offer/variant columns and writes only the current 20-column schema. The previous 19-column CSV without ads_metadata is also accepted; old out-of-stock rows carry an empty metadata cell. Other malformed previous files stop generation without replacing the cache. The 48-hour window gives OpenAI time to process at least the out-of-stock upload; successful SFTP alone does not prove ingestion. Check Upload History before treating retirement as accepted.
The reservation-sync job must keep these tags current. Google labels tagged rows; this feed excludes them entirely. A manual override of Google's custom label does not override this feed's source-tag exclusion.
Optional fields
Chosen: Include source-backed item identity, GTIN, extra images, color, material and product size. Omit MPN, category, structured dimensions/weight, shipping charges, seller URL, returns, reviews and country columns until their sources, units or account configuration are verified. Do not manufacture identifiers, return-policy URLs or unsupported metadata beyond the five established custom labels. Automatic labels are cached by the French Google Merchant export and may lag until that export refreshes; manual overrides are applied when this feed runs. Malformed/unassigned GTINs stay empty; missing optional attributes do not reject standalone products.
The native contract's nine universally required fields and Ads eligibility are emitted. Optional offer and variant columns are omitted for these independent listings. Marketplace-seller metadata is not applicable to these MENZZO retail offers. Checkout policies and account readiness must be confirmed before accepting the current checkout-eligible export behavior. This is the native schema, not the Google-compatible alternative with its different identifier requirements.
Configuration and execution
Use the deployment-supplied SFTP account on port 443, with root upload path /. The account identifier and password belong only in approved deployment configuration.
Merge this block into the existing parameters: map in app/config/parameters.yml:
yaml
chatgptads_sftp:
host: sftp.commerce.openai.com
port: 443
username: '<deployment-supplied-username>'
password: 'SET_LOCALLY'
host_key: '' # Optional server identity pin
upload_path: /Chosen: parameters.yml.dist documents the complete block; deployments supply it through the existing parameters.yml flow. No separate ChatGPT configuration file or extra import is added. Populate the entire block before clearing/warming the Symfony container or running the command. Credentials can remain blank for --no-upload, but the parameter block itself must exist. Store secrets only in deployment configuration.
Password authentication uses the configured username and password. host_key is optional: omit it or leave it blank for the password-only configuration. When supplied, it pins the server SSH public key as algorithm and base64 key, without hostname or comment; a mismatch blocks login before credentials are sent. Obtain any configured pin through a trusted OpenAI/admin channel. This is server identity verification, not SSH-key login. Private-key authentication is not implemented. Passwords are not accepted on the command line or written to logs.
The following production command still reads production configuration and catalog data and writes a local artifact. It requires explicit approval for this exact command and target before execution, even though it does not contact SFTP:
sh
php bin/console menzzo:chatgptads:export --env=prod --no-debug --no-uploadThe default output is var/exports/chatgptads/chatgptads.csv. Choose a different local file with --output=/absolute/path/chatgptads.csv; stream URLs and empty paths are rejected. Check the exported count, skipped-row logs, header and representative rows. A local CSV is not proof of OpenAI acceptance.
The following command replaces the remote /chatgptads.csv. It requires separate, explicit approval for this exact command and production target before execution:
sh
php bin/console menzzo:chatgptads:export --env=prod --no-debugChosen: Every normal run regenerates the CSV before sending it. There is no upload-old-file option. The remote filename is always /chatgptads.csv, independent of the local output filename. Do not run the old partners index.php command.
Failure handling and scheduling
Chosen: The exporter writes a unique temporary file next to the destination, checks writes/flush and atomically replaces the local CSV only after validation. An empty/all-invalid catalog, duplicate item ID or write failure preserves the previous local CSV and prevents upload. Invalid products are skipped individually. Logs record skipped counts and command errors, including exception context.
The Symfony command lock prevents overlapping runs of this command on one host, including --no-upload runs. Run it on one scheduler host; it is not a distributed lock. The lock spans generation and upload and is released after failure. Keep the output under the command's ownership; external writers are not coordinated by this lock.
Upload the single file directly to the SFTP root. Reusing the stable filename replaces the prior catalog. No dated copies, nested folders, remote temporary files or automatic deletions are introduced. If old manually uploaded filenames exist in the remote root, an operator must reconcile them: OpenAI considers all root feed files. A failed transfer may leave a partial remote file. Inspect Upload History and rerun a full upload after resolving the cause; there is no claimed atomic remote replacement or rollback.
Chosen operational guidance: Schedule after upstream catalog synchronization, at least hourly and after material price/stock/promotion changes. The cron entry is not installed by this change. Use the project's normal scheduler registration and log/alerting process; do not hand-edit the generated system-crontab.md. Monitor exit codes, skipped-row warnings, CSV modification times, transferred counts, and OpenAI processing results. Products expire after two weeks without refresh.
Cutover and acceptance
Every production deployment, diagnostic, generation, upload, schedule, or rollback step below requires explicit approval for its exact command and target.
- Deploy the merged Logidav implementation and configure credentials/server identity.
- Run
--no-upload, compare IDs/counts against the intended catalog, and inspect stock, EUR prices, images and standalone listing fields. Do not force a target count with a limit. - Run the normal command and inspect this feed's OpenAI Upload History through a terminal state. Resolve rejected rows and compare accepted counts and Ads eligibility.
- Install and verify the Logidav schedule. Confirm the merged partners removal is deployed. Existing cached files can be cleaned up separately through deployment; the cleanup PR does not delete production files remotely.
Verified locally: PHP 7.3.33 with the repository's pinned PHPUnit 6.5.14 passes exporter fixtures covering all 20 fields, CSV quotes/backslashes/CRLF, sales, stock, GTIN and independent listing handling, rejection diagnostics, duplicate/empty catalogs and atomic preservation. Repository checks execute the actual query through DBAL and SQLite, covering source ID, dropship, active/new/visibility, EAN, all three stores, exclusions and duplicate auxiliary records. CommandTester checks fresh uploads, no-upload mode, failure propagation, previous-file preservation and locking. The new service definitions compile in a focused Symfony container with the standard parameter template and command autoconfiguration. Fake SFTP checks cover server identity before login, authentication failures, exact file bytes and root filename. These do not contact the OpenAI server.
Open: Full production container boot, MySQL catalog completeness, installed cron, real SFTP transfer, accepted/rejected rows, France/EUR account support and owner acceptance. Local tests and a successful transfer do not establish ingestion. The old generic Hosted URL preview failure was not a confirmed CSV delimiter problem; delivery now uses the owner-selected SFTP path instead.
Rollback: Stop the new cron and restore the prior Logidav release through the normal process. If ads must stop, pause the dependent campaigns/ad groups in Ads Manager; stopping refreshes or SFTP does not immediately remove previously ingested products. There is no schema migration. Do not reactivate the retired partners generator without an explicit decision to restore that ownership.
Sources and maintenance
Official requirements checked on 18 September 2026:
Code: ExportChatgptAdsCommand, ChatgptAdsFeedExporter, ChatgptAdsSftpUploader, ProductRepository::findChatgptAdsFeedRows, and app/config/parameters.yml.dist. Recheck schema and account support when changing fields. Keep this record aligned with the final implementation and observed deployment evidence.